From 75e9cadcd54caa80a155ed5ad6fdf6d44f4a58eb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 16 Jul 2026 03:57:54 +0700 Subject: [PATCH] docs(plans): tambah plan implementasi rombak context & compaction 9 task: tokens.rs (tiktoken-rs), window.rs, dedup.rs, squash.rs, shaping.rs (port shortsend), gabungan cutover+fix /compact manual, wiring squash, status bar, dan mode ringkas opsional. Setiap task diverifikasi dengan cargo build/test nyata, bukan -D warnings (yang sudah merah di main karena warning pre-existing di file lain). Co-Authored-By: Claude Sonnet 5 --- .../2026-07-16-context-compaction-overhaul.md | 1884 +++++++++++++++++ 1 file changed, 1884 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-context-compaction-overhaul.md diff --git a/docs/superpowers/plans/2026-07-16-context-compaction-overhaul.md b/docs/superpowers/plans/2026-07-16-context-compaction-overhaul.md new file mode 100644 index 0000000..a035301 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-context-compaction-overhaul.md @@ -0,0 +1,1884 @@ +# Context & Compaction Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `src/app/runtime/shortsend.rs` with a `src/app/runtime/context/` module +(tokens, dedup, squash, shaping, window) that keeps conversation context lean from turn 1 — +deduplicating repeated tool calls, compressing large tool outputs at capture time, and fixing +three known inconsistencies (divergent token heuristics, manual/auto `/compact` asymmetry, +triplicated `context_window` resolution) — plus an optional off-by-default "concise mode" +system-prompt toggle. + +**Architecture:** Five focused files under `src/app/runtime/context/`, each independently +testable, called directly from `run_agent_turn`'s auto-compaction loop and from +`Action::Compact` (no orchestration facade — this codebase has no DI layer; see +CLAUDE.md). `squash` runs once per tool result at construction time; `dedup` runs every turn +unconditionally; `shaping` (the old `shortsend` logic, unchanged behavior) runs only when the +hysteresis threshold trips. + +**Tech Stack:** Rust 2021, `tiktoken-rs` (new dependency), `sha2`/`hex`/`serde_json` +(already dependencies). + +## Global Constraints + +- Every `pub fn`/`pub struct`/`pub enum`/`pub trait` and every non-trivial private function + (≥10 lines) needs a doc comment covering What/Flow/Why/Return, per CLAUDE.md. +- Never add `#[allow(...)]` lint-bypass attributes. Fix the underlying issue instead. +- Tests live inline in `#[cfg(test)] mod tests` blocks in the same file, not under `tests/`. +- Commit convention: Conventional Commits in Bahasa Indonesia (see the `commit-convention` + skill) — every commit step in this plan already follows that format; keep it consistent if + you deviate. +- Verify each task with `cargo build` and `cargo test ` (both pass cleanly today). + Do **not** gate on `cargo clippy -- -D warnings` — that command already fails on `main` + from pre-existing warnings in unrelated files (`view/markdown.rs`, `main.rs`, etc.); it is + not this plan's job to fix those. Instead, after each task, spot-check for *new* warnings + with `cargo clippy --message-format=short 2>&1 | grep ` and expect + no output. +- `serde_json::Value` objects in this codebase are NOT built with the `preserve_order` + feature (confirmed: absent from `Cargo.toml`/`Cargo.lock`), so `serde_json::Map` is + backed by a `BTreeMap` and `serde_json::to_string` always emits object keys in sorted + order. Don't add manual key-sorting for JSON canonicalization — it's already canonical. + +--- + +### Task 1: `tiktoken-rs` dependency + `context/tokens.rs` + +**Files:** +- Modify: `Cargo.toml` +- Modify: `src/app/runtime/mod.rs` +- Create: `src/app/runtime/context/mod.rs` +- Create: `src/app/runtime/context/tokens.rs` + +**Interfaces:** +- Produces: `pub fn context::tokens::count_tokens(text: &str) -> usize`, + `pub fn context::tokens::count_message_tokens(msg: &ChatMessage) -> usize` — every later + task that needs a token count uses these two functions exclusively. + +- [ ] **Step 1: Add the dependency** + +Run: `cargo add tiktoken-rs@0.12` +Expected: `Cargo.toml` gains a `tiktoken-rs = "0.12"` line under `[dependencies]`, +`Cargo.lock` updates. This crate embeds its BPE vocab files via `include_str!` at compile +time (verified: no `reqwest`/`http`/network dependency anywhere in its own `Cargo.toml`) — +no runtime network access, consistent with zesdex's `InternetMode` gating. + +- [ ] **Step 2: Register the new module directory** + +In `src/app/runtime/mod.rs`, add `pub mod context;` alongside the existing modules (leave +`pub mod shortsend;` in place for now — it's deleted in Task 6 once every call site has +moved over): + +```rust +pub mod actions; +pub mod commands; +pub mod context; +pub mod shortsend; +pub mod stream; +``` + +Create `src/app/runtime/context/mod.rs`: + +```rust +//! Context management: token counting, cross-call tool-result dedup, +//! per-result compression, budget-based shaping, and shared +//! context-window resolution — replaces `runtime::shortsend`. +//! +//! No facade function here: `dedup`, `shaping`, and `tokens` are called +//! directly from each call site (the per-turn auto-compaction loop in +//! `actions::run_agent_turn`, and `Action::Compact`), matching this +//! codebase's "no DI, call modules directly" convention. An orchestration +//! layer would only serve one of the two callers generically — the +//! auto-loop already needs per-stage control to decide when to emit +//! `TurnEvent::Compacted`. + +pub mod tokens; +``` + +(`dedup`, `shaping`, `squash`, `window` are added to this list in later tasks as their files +are created — adding `pub mod` lines for files that don't exist yet won't compile.) + +- [ ] **Step 3: Write the failing tests** + +Create `src/app/runtime/context/tokens.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::chat::message::ChatMessage; + + #[test] + fn empty_string_has_zero_tokens() { + assert_eq!(count_tokens(""), 0); + } + + #[test] + fn known_short_phrase_has_expected_token_count() { + // Verified empirically against tiktoken-rs 0.12's o200k_base: + // "hello world" -> [24912, 2375], i.e. 2 tokens. + assert_eq!(count_tokens("hello world"), 2); + } + + #[test] + fn known_code_snippet_has_expected_token_count() { + // Verified empirically: 9 tokens under o200k_base. + assert_eq!(count_tokens("fn main() { println!(\"hi\"); }"), 9); + } + + #[test] + fn message_with_no_content_counts_zero() { + let msg = ChatMessage::assistant(None); + assert_eq!(count_message_tokens(&msg), 0); + } + + #[test] + fn message_token_count_matches_count_tokens_on_its_content() { + let msg = ChatMessage::user("hello world"); + assert_eq!(count_message_tokens(&msg), count_tokens("hello world")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --bin zesdex context::tokens::tests` +Expected: compile error — `count_tokens`/`count_message_tokens` not found (not yet defined +above the test module). + +- [ ] **Step 3: Write the implementation** + +Add above the `#[cfg(test)]` block in the same file: + +```rust +//! Unified token-count estimation for context-window budgeting. +//! +//! Flow: text -> `tiktoken_rs::o200k_base_singleton()` (BPE vocab embedded +//! in the binary via `include_str!`, no network access) -> `encode_ordinary` +//! -> token count. +//! +//! Why: replaces three independent char-count heuristics that disagreed +//! with each other (`/3` in the old `shortsend.rs`, `/4` in the turn +//! loop, `/4` again in the status bar) with one real BPE tokenizer. +//! `o200k_base` is an approximation for non-OpenAI providers but is far +//! closer than a flat byte-per-token guess; it's only used for the +//! 85%/95% budget thresholds, not for billing-accurate counts. + +use crate::dto::chat::message::ChatMessage; + +/// Count tokens in a single string under `o200k_base`. +/// +/// Return: the BPE token count for `text`. `encode_ordinary` (not +/// `encode`/`encode_with_special_tokens`) is used deliberately — message +/// content that happens to contain a special-token-shaped substring +/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted +/// as ordinary text, not interpreted as a control token. +pub fn count_tokens(text: &str) -> usize { + tiktoken_rs::o200k_base_singleton().encode_ordinary(text).len() +} + +/// Count tokens in a `ChatMessage`'s text content. +/// +/// Return: 0 for a message with no `content` (e.g. an assistant message +/// that only carries `tool_calls`). +pub fn count_message_tokens(msg: &ChatMessage) -> usize { + msg.content.as_deref().map_or(0, count_tokens) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --bin zesdex context::tokens::tests` +Expected: `test result: ok. 5 passed; 0 failed` + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml Cargo.lock src/app/runtime/mod.rs src/app/runtime/context/mod.rs src/app/runtime/context/tokens.rs +git commit -m "feat(context): tambah context::tokens dengan tiktoken-rs + +Ganti tiga heuristik char-count (/3 di shortsend, /4 di loop turn, /4 +di status bar) yang saling tidak konsisten dengan satu BPE tokenizer +nyata. tiktoken-rs membundel vocab lewat include_str! saat build, jadi +tidak ada akses jaringan saat runtime." +``` + +--- + +### Task 2: `context/window.rs` + +**Files:** +- Modify: `src/app/runtime/context/mod.rs` +- Create: `src/app/runtime/context/window.rs` + +**Interfaces:** +- Consumes: `crate::model::app_config::AppConfig`, `crate::model::settings::Settings` + (existing types, both already `pub`). +- Produces: `pub fn context::window::resolve(app_config: &AppConfig, settings: &Settings) -> usize` + — Task 6 (`spawn_turn`/`Action::Compact`) and Task 8 (status bar) both call this. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/runtime/context/window.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::model::app_config::{AppConfig, ModelRole}; + use crate::model::settings::Settings; + + #[test] + fn resolves_context_window_from_matching_model_role() { + let mut app_config = AppConfig::default(); + app_config.model_roles.insert("default".to_string(), ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: Some(128_000), + temperature: None, + }); + let mut settings = Settings::default(); + settings.provider = "zen".to_string(); + settings.model = "deepseek-v4-flash-free".to_string(); + + assert_eq!(resolve(&app_config, &settings), 128_000); + } + + #[test] + fn falls_back_to_default_context_window_when_no_role_matches() { + let app_config = AppConfig::default(); + let mut settings = Settings::default(); + settings.provider = "nonexistent".to_string(); + settings.model = "nonexistent-model".to_string(); + + assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize); + } + + #[test] + fn falls_back_to_default_when_matching_role_has_no_context_window_set() { + let mut app_config = AppConfig::default(); + app_config.model_roles.insert("default".to_string(), ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: None, + temperature: None, + }); + let mut settings = Settings::default(); + settings.provider = "zen".to_string(); + settings.model = "deepseek-v4-flash-free".to_string(); + + assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --bin zesdex context::window::tests` +Expected: compile error — `resolve` not found. + +- [ ] **Step 3: Write the implementation** + +Add above the test block: + +```rust +//! Single source of truth for resolving the active model's context +//! window size, replacing three copies of the same lookup that had +//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each +//! had their own inline version — the status bar's copy additionally +//! displayed "?" on no match instead of falling back like the other two, +//! an inconsistency this unifies away). + +use crate::model::app_config::AppConfig; +use crate::model::settings::Settings; + +/// Resolve the context-window size (in tokens) for the currently +/// configured provider/model. +/// +/// Flow: find the `ModelRole` whose `provider`+`model` match +/// `settings` -> use its `context_window` if set -> otherwise fall back +/// to `app_config.default_context_window`. +/// +/// Return: always a concrete token count, never "unknown". +pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize { + app_config.model_roles.values() + .find(|role| role.provider == settings.provider && role.model == settings.model) + .and_then(|role| role.context_window) + .unwrap_or(app_config.default_context_window) as usize +} +``` + +In `src/app/runtime/context/mod.rs`, add `pub mod window;` to the module list. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --bin zesdex context::window::tests` +Expected: `test result: ok. 3 passed; 0 failed` + +- [ ] **Step 5: Commit** + +```bash +git add src/app/runtime/context/mod.rs src/app/runtime/context/window.rs +git commit -m "feat(context): tambah context::window::resolve + +Satukan tiga salinan logika resolusi context_window (Action::Compact, +spawn_turn, status bar) yang sempat melenceng satu sama lain." +``` + +--- + +### Task 3: `context/dedup.rs` + +**Files:** +- Modify: `src/app/subagent/division.rs` +- Modify: `src/app/runtime/context/mod.rs` +- Create: `src/app/runtime/context/dedup.rs` + +**Interfaces:** +- Consumes: `crate::app::subagent::division::tool_scope::READ_TOOLS` (made `pub` in this + task), `crate::dto::chat::message::{ChatMessage, Role}`, `crate::dto::chat::tool::ToolCall`. +- Produces: `pub fn context::dedup::collapse(messages: &[ChatMessage]) -> (Vec, bool)` + — the `bool` is `true` iff at least one message was replaced. Task 6 calls this from both + the auto-loop and the manual `/compact` path. + +- [ ] **Step 1: Make `READ_TOOLS` public** + +In `src/app/subagent/division.rs`, change: + +```rust + const READ_TOOLS: &[&str] = &[ +``` + +to: + +```rust + /// The read-only tool set — reused by `context::dedup` as the + /// authoritative "safe to deduplicate" classification, so there's a + /// single list of read-only tool names in the codebase instead of two. + pub const READ_TOOLS: &[&str] = &[ +``` + +Run: `cargo build` — expect it still succeeds (widening `const` to `pub const` is additive; +existing test module in the same file uses `tools_for` and other constants unaffected). + +- [ ] **Step 2: Write the failing tests** + +Create `src/app/runtime/context/dedup.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::chat::message::ChatMessage; + use crate::dto::chat::tool::{ToolCall, ToolFunction}; + use serde_json::json; + + fn assistant_with_call(id: &str, name: &str, args: serde_json::Value) -> ChatMessage { + let mut m = ChatMessage::assistant(None); + m.tool_calls = Some(vec![ToolCall { + id: id.to_string(), + type_: "function".to_string(), + function: ToolFunction { name: name.to_string(), arguments: args }, + }]); + m + } + + #[test] + fn older_result_of_same_read_tool_and_args_is_replaced() { + let messages = vec![ + assistant_with_call("call-1", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-1".to_string(), "first read of a.rs".to_string()), + assistant_with_call("call-2", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-2".to_string(), "second read of a.rs".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(changed); + assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER)); + assert_eq!(result[3].content.as_deref(), Some("second read of a.rs")); + } + + #[test] + fn different_arguments_are_not_deduplicated() { + let messages = vec![ + assistant_with_call("call-1", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-1".to_string(), "read of a.rs".to_string()), + assistant_with_call("call-2", "read", json!({"path": "b.rs"})), + ChatMessage::tool_result("call-2".to_string(), "read of b.rs".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[1].content.as_deref(), Some("read of a.rs")); + assert_eq!(result[3].content.as_deref(), Some("read of b.rs")); + } + + #[test] + fn key_order_in_arguments_does_not_prevent_dedup() { + let messages = vec![ + assistant_with_call("call-1", "grep", json!({"pattern": "foo", "path": "."})), + ChatMessage::tool_result("call-1".to_string(), "first grep".to_string()), + assistant_with_call("call-2", "grep", json!({"path": ".", "pattern": "foo"})), + ChatMessage::tool_result("call-2".to_string(), "second grep".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(changed); + assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER)); + } + + #[test] + fn mutating_tool_with_identical_args_is_never_deduplicated() { + let messages = vec![ + assistant_with_call("call-1", "bash", json!({"command": "cargo test"})), + ChatMessage::tool_result("call-1".to_string(), "first run: 3 failed".to_string()), + assistant_with_call("call-2", "bash", json!({"command": "cargo test"})), + ChatMessage::tool_result("call-2".to_string(), "second run: 0 failed".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[1].content.as_deref(), Some("first run: 3 failed")); + assert_eq!(result[3].content.as_deref(), Some("second run: 0 failed")); + } + + #[test] + fn tool_result_with_no_matching_call_is_left_untouched() { + let messages = vec![ + ChatMessage::tool_result("orphan-id".to_string(), "some result".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[0].content.as_deref(), Some("some result")); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test --bin zesdex context::dedup::tests` +Expected: compile error — `collapse`/`DUPLICATE_PLACEHOLDER` not found. + +- [ ] **Step 4: Write the implementation** + +Add above the test block: + +```rust +//! Cross-call tool-result deduplication: when a read-only tool is called +//! again with identical arguments, the earlier result is replaced with a +//! placeholder so only the latest copy occupies context. +//! +//! Flow: pair each `Role::Tool` message to its originating `ToolCall` via +//! `tool_call_id` -> key on `(function.name, sha256(canonical_json(args)))` +//! -> for read-only tools, keep only the last occurrence of each key in +//! full, placeholder the rest. +//! +//! Why: reading the same file (or re-running the same grep) twice in a +//! session otherwise keeps both full copies in context until compaction +//! eventually drops the older one wholesale, along with everything else +//! from that period. Mutating tools (`write`, `edit`, `bash`, `delete`, +//! `git_operator`, ...) are never touched, even with identical +//! arguments, because call order and repetition can be semantically +//! meaningful (e.g. retrying a flaky `bash` command until it passes). + +use std::collections::HashMap; +use sha2::Digest; +use crate::app::subagent::division::tool_scope::READ_TOOLS; +use crate::dto::chat::message::{ChatMessage, Role}; + +const DUPLICATE_PLACEHOLDER: &str = + "[duplicate result — superseded by a later identical call, see below]"; + +/// Replace superseded read-only tool results with a placeholder. +/// +/// Return: a `Vec` the same length as `messages`, and +/// `true` iff at least one entry was replaced. The caller uses the +/// `bool` to decide whether the result is worth persisting/announcing, +/// without `ChatMessage` needing to implement `PartialEq`. +pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { + // tool_call_id -> (tool name, canonical JSON of its arguments) + let mut call_info: HashMap = HashMap::new(); + for m in messages { + if let Some(calls) = &m.tool_calls { + for call in calls { + let canonical = serde_json::to_string(&call.function.arguments).unwrap_or_default(); + call_info.insert(call.id.clone(), (call.function.name.clone(), canonical)); + } + } + } + + // For each (tool, args-hash) key among read-only tools, find the + // index of its LAST occurrence — that's the one kept in full. + let mut last_index_for_key: HashMap = HashMap::new(); + for (idx, m) in messages.iter().enumerate() { + if m.role != Role::Tool { + continue; + } + let Some(id) = &m.tool_call_id else { continue }; + let Some((name, args)) = call_info.get(id) else { continue }; + if !READ_TOOLS.contains(&name.as_str()) { + continue; + } + last_index_for_key.insert(dedup_key(name, args), idx); + } + + let mut changed = false; + let result = messages.iter().enumerate().map(|(idx, m)| { + if m.role != Role::Tool { + return m.clone(); + } + let Some(id) = &m.tool_call_id else { return m.clone() }; + let Some((name, args)) = call_info.get(id) else { return m.clone() }; + if !READ_TOOLS.contains(&name.as_str()) { + return m.clone(); + } + let key = dedup_key(name, args); + if last_index_for_key.get(&key) == Some(&idx) { + return m.clone(); + } + changed = true; + ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string()) + }).collect(); + + (result, changed) +} + +/// Build the dedup key for a tool call. +/// +/// Why hash the arguments: keeps the key a fixed, short size regardless +/// of argument payload size. `serde_json::to_string` is already +/// canonical here — this codebase doesn't enable serde_json's +/// `preserve_order` feature, so `Value::Object` is backed by a +/// `BTreeMap` and always serializes keys in sorted order. +fn dedup_key(tool_name: &str, canonical_args: &str) -> String { + let hash = hex::encode(sha2::Sha256::digest(canonical_args.as_bytes())); + format!("{tool_name}:{hash}") +} +``` + +In `src/app/runtime/context/mod.rs`, add `pub mod dedup;` to the module list. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test --bin zesdex context::dedup::tests` +Expected: `test result: ok. 6 passed; 0 failed` + +- [ ] **Step 6: Commit** + +```bash +git add src/app/subagent/division.rs src/app/runtime/context/mod.rs src/app/runtime/context/dedup.rs +git commit -m "feat(context): tambah context::dedup untuk hasil tool yang berulang + +Panggilan tool read-only (read, grep, glob, dst) dengan argumen persis +sama menyisakan satu salinan penuh saja di context; entri lama diganti +placeholder tapi tool-call-nya sendiri tetap terlihat di riwayat. Tool +bersifat mutasi (write/edit/bash/dll) tidak pernah disentuh. + +Jadikan tool_scope::READ_TOOLS pub supaya jadi satu-satunya sumber +klasifikasi read-only, dipakai ulang bukan didaftar dua kali." +``` + +--- + +### Task 4: `context/squash.rs` + +**Files:** +- Modify: `src/app/runtime/context/mod.rs` +- Create: `src/app/runtime/context/squash.rs` + +**Interfaces:** +- Produces: `pub fn context::squash::apply(tool_name: &str, output: &str) -> String` — + Task 7 calls this at the tool-result construction site. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/runtime/context/squash.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_under_the_floor_passes_through_unchanged() { + let small = "short output"; + assert_eq!(apply("bash", small), small); + } + + #[test] + fn read_tool_output_is_never_squashed_even_when_huge_json() { + let big_json = format!( + "{{\"description\": \"{}\"}}", + "a very long description value that repeats ".repeat(100), + ); + assert!(big_json.len() > SQUASH_FLOOR_BYTES); + assert_eq!(apply("read", &big_json), big_json); + } + + #[test] + fn json_output_over_floor_keeps_structure_and_short_values() { + let value = serde_json::json!({ + "id": "abc123", + "note": "hi", + "description": "a very long description value that repeats ".repeat(100), + }); + let text = serde_json::to_string(&value).unwrap(); + assert!(text.len() > SQUASH_FLOOR_BYTES); + + let result = apply("some_mcp_tool", &text); + let parsed: serde_json::Value = serde_json::from_str(&result) + .expect("squashed JSON must still be valid JSON"); + + assert_eq!(parsed["id"], "abc123", "short values must survive"); + assert_eq!(parsed["note"], "hi", "short values must survive"); + assert_ne!( + parsed["description"].as_str().unwrap().len(), + value["description"].as_str().unwrap().len(), + "long low-entropy value must be shrunk", + ); + } + + #[test] + fn json_array_elements_past_third_are_squashed_harder() { + let long_str = "the quick brown fox jumps over the lazy dog again and again ".repeat(5); + let value = serde_json::json!({ + "items": [long_str.clone(), long_str.clone(), long_str.clone(), long_str.clone()], + }); + let text = serde_json::to_string(&value).unwrap(); + assert!(text.len() > SQUASH_FLOOR_BYTES); + + let result = apply("some_mcp_tool", &text); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + let items = parsed["items"].as_array().unwrap(); + + assert_eq!(items[0].as_str().unwrap(), long_str, "first 3 elements keep long values as-is (not high-entropy but under the array cutoff)"); + assert_ne!(items[3].as_str().unwrap(), long_str, "4th element must be elided regardless of content"); + } + + #[test] + fn log_like_output_keeps_error_lines_and_marks_omissions() { + let mut lines = vec!["build started".to_string()]; + for i in 0..200 { + lines.push(format!("info: compiling module {i}")); + } + lines.push("error: something failed at the end".to_string()); + let text = lines.join("\n"); + assert!(text.len() > SQUASH_FLOOR_BYTES); + + let result = apply("bash", &text); + + assert!(result.contains("error: something failed at the end")); + assert!(result.contains("lines omitted")); + assert!(result.len() < text.len()); + } + + #[test] + fn generic_large_text_is_truncated_with_omission_marker() { + let lines: Vec = (0..500).map(|i| format!("line number {i} of plain output")).collect(); + let text = lines.join("\n"); + assert!(text.len() > SQUASH_FLOOR_BYTES); + + let result = apply("bash", &text); + + assert!(result.contains("line number 0 of plain output"), "keeps head"); + assert!(result.contains("line number 499 of plain output"), "keeps tail"); + assert!(result.contains("lines omitted")); + assert!(result.len() < text.len()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --bin zesdex context::squash::tests` +Expected: compile error — `apply`/`SQUASH_FLOOR_BYTES` not found. + +- [ ] **Step 3: Write the implementation** + +Add above the test block: + +```rust +//! Per-tool-result compression: shrink large tool outputs before they +//! ever enter conversation history, dispatching by content shape. +//! +//! Flow: `apply(tool_name, output)` -> `read` tool or under the size +//! floor? pass through unchanged : valid JSON? `squash_json` : looks +//! log-shaped? `squash_log` : `squash_generic`. +//! +//! Why: a single large `bash`/`grep` result can dominate a +//! conversation's token budget even on its first occurrence, long +//! before `dedup`/`shaping` ever get a chance to act on repeats or +//! overall budget. + +use std::collections::HashSet; + +/// Below this size, compression isn't worth the risk of losing detail — +/// pass the output through unchanged. +const SQUASH_FLOOR_BYTES: usize = 1500; + +/// Byte budget for the generic fallback compressor — double the squash +/// floor, so the fallback path still yields a real reduction on +/// anything that triggered it. +const GENERIC_BUDGET_BYTES: usize = SQUASH_FLOOR_BYTES * 2; + +/// Tools whose output must never be altered. `read` is exempted because +/// its output must stay byte-exact — the agent relies on it for +/// exact-match edits afterward, and squashing a file that happens to +/// parse as JSON (e.g. `package.json`) would silently corrupt the +/// agent's view of real file content. +const NEVER_SQUASH: &[&str] = &["read"]; + +/// Compress a tool's raw output before it's stored in conversation +/// history. +/// +/// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at +/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from +/// whichever detector matches its content shape. +pub fn apply(tool_name: &str, output: &str) -> String { + if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES { + return output.to_string(); + } + if serde_json::from_str::(output).is_ok() { + return squash_json(output); + } + if looks_log_shaped(output) { + return squash_log(output); + } + squash_generic(output, GENERIC_BUDGET_BYTES) +} + +/// Compress a JSON tool result by keeping all structural content (keys, +/// array/object shape) and eliding long, low-entropy string *values*, +/// while keeping short values (<=20 chars) and high-entropy ones (UUIDs, +/// hashes, paths) intact. Array elements past the first 3 are elided +/// regardless of length/entropy. +/// +/// Why walk a parsed `Value` instead of hand-rolling a JSON tokenizer: +/// `serde_json` already handles escaping/nesting correctly (this +/// codebase's own `dto::chat::tool::repair_json` exists specifically to +/// work around how easy it is to get that wrong by hand) — reusing it +/// is both simpler and more robust. +/// +/// Return: re-serialized JSON with the same shape as the input. +fn squash_json(text: &str) -> String { + let Ok(mut value) = serde_json::from_str::(text) else { + return text.to_string(); + }; + squash_json_value(&mut value, false); + serde_json::to_string(&value).unwrap_or_else(|_| text.to_string()) +} + +/// Recursively elide long, low-entropy string values in place. +/// `in_late_array` is true once past the first 3 elements of an +/// enclosing array, tightening the elision rule for the rest of it. +fn squash_json_value(value: &mut serde_json::Value, in_late_array: bool) { + match value { + serde_json::Value::String(s) => { + let keep = !in_late_array && (s.len() <= 20 || shannon_entropy(s) >= 0.85); + if !keep { + *s = "…".to_string(); + } + } + serde_json::Value::Array(items) => { + for (i, item) in items.iter_mut().enumerate() { + squash_json_value(item, i >= 3); + } + } + serde_json::Value::Object(map) => { + for v in map.values_mut() { + squash_json_value(v, false); + } + } + _ => {} + } +} + +/// Shannon entropy in bits per character — used to distinguish +/// high-entropy strings (UUIDs, hashes, random IDs, worth keeping) from +/// low-entropy prose (safe to elide). +fn shannon_entropy(s: &str) -> f64 { + if s.is_empty() { + return 0.0; + } + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for c in s.chars() { + *counts.entry(c).or_insert(0) += 1; + } + let len = s.chars().count() as f64; + counts.values() + .map(|&count| { + let p = f64::from(u32::try_from(count).unwrap_or(u32::MAX)) / len; + -p * p.log2() + }) + .sum() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +fn classify_line(line: &str) -> LogLevel { + let lower = line.to_lowercase(); + if lower.contains("error") || lower.contains("fail") || lower.contains("panic") { + LogLevel::Error + } else if lower.contains("warn") { + LogLevel::Warn + } else if lower.contains("debug") || lower.contains("trace") { + LogLevel::Debug + } else { + LogLevel::Info + } +} + +/// Heuristic gate for routing to `squash_log` vs `squash_generic`: at +/// least 3 lines that look like error/warning/stack-trace output. +fn looks_log_shaped(text: &str) -> bool { + let hits = text.lines().filter(|l| { + let lower = l.to_lowercase(); + lower.contains("error") || lower.contains("warn") || lower.contains("fail") + || lower.contains("panic") || l.trim_start().starts_with("at ") + }).count(); + hits >= 3 +} + +/// Compress log-shaped output: keep up to 20 highest-scored error lines +/// and up to 10 highest-scored warning lines (score = level weight + +/// 0.3 if the line looks like a stack-trace frame), each with a +/// +/-2-line context window, replacing every gap with a `[N lines +/// omitted]` marker. +/// +/// Why not a comment-shaped marker (e.g. `// N lines omitted`): the +/// `rtk` project's own regression tests found that shape gets parsed by +/// the LLM as code and triggers a retry loop. +fn squash_log(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let levels: Vec = lines.iter().map(|l| classify_line(l)).collect(); + + let score = |i: usize| -> f32 { + let level_score = match levels[i] { + LogLevel::Error => 1.0, + LogLevel::Warn => 0.5, + LogLevel::Info => 0.1, + LogLevel::Debug => 0.05, + }; + let stack_boost = if lines[i].trim_start().starts_with("at ") { 0.3 } else { 0.0 }; + level_score + stack_boost + }; + + let mut error_idxs: Vec = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Error).collect(); + error_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal)); + error_idxs.truncate(20); + + let mut warn_idxs: Vec = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Warn).collect(); + warn_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal)); + warn_idxs.truncate(10); + + let mut keep: HashSet = HashSet::new(); + for &i in error_idxs.iter().chain(warn_idxs.iter()) { + let lo = i.saturating_sub(2); + let hi = (i + 2).min(lines.len().saturating_sub(1)); + keep.extend(lo..=hi); + } + + if keep.is_empty() { + return squash_generic(text, GENERIC_BUDGET_BYTES); + } + + render_kept_lines(&lines, &keep) +} + +/// Importance-ranked truncation for content that isn't JSON or +/// log-shaped: keep the first 10 and last 10 lines, plus any +/// non-blank line that isn't a repeat of the one before it, until +/// `budget` bytes are used. +fn squash_generic(text: &str, budget: usize) -> String { + let lines: Vec<&str> = text.lines().collect(); + if lines.len() <= 20 { + return text.chars().take(budget).collect(); + } + + let head_end = 10; + let tail_start = lines.len() - 10; + let mut keep: HashSet = (0..head_end).chain(tail_start..lines.len()).collect(); + + let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::() + + lines[tail_start..].iter().map(|l| l.len() + 1).sum::(); + let mut prev = ""; + for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) { + let non_trivial = !line.trim().is_empty() && line != prev; + if non_trivial && used + line.len() + 1 <= budget { + keep.insert(i); + used += line.len() + 1; + } + prev = line; + } + + render_kept_lines(&lines, &keep) +} + +/// Render a subset of `lines` in order, inserting a `[N lines omitted]` +/// marker at every gap between kept lines. +fn render_kept_lines(lines: &[&str], keep: &HashSet) -> String { + let mut kept_sorted: Vec = keep.iter().copied().collect(); + kept_sorted.sort_unstable(); + + let mut out = String::new(); + let mut cursor = 0usize; + for &i in &kept_sorted { + if i > cursor { + out.push_str(&format!("[{} lines omitted]\n", i - cursor)); + } + out.push_str(lines[i]); + out.push('\n'); + cursor = i + 1; + } + if cursor < lines.len() { + out.push_str(&format!("[{} lines omitted]\n", lines.len() - cursor)); + } + out +} +``` + +In `src/app/runtime/context/mod.rs`, add `pub mod squash;` to the module list. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --bin zesdex context::squash::tests` +Expected: `test result: ok. 6 passed; 0 failed` + +- [ ] **Step 5: Commit** + +```bash +git add src/app/runtime/context/mod.rs src/app/runtime/context/squash.rs +git commit -m "feat(context): tambah context::squash untuk kompresi hasil tool + +Kompresi per-jenis-konten (JSON: pertahankan struktur & value pendek/ +entropi tinggi, buang value panjang bertele-tele; log: simpan baris +error/warning berskor tertinggi + konteks sekitarnya; generic: potong +importance-ranked) untuk hasil tool di atas 1.5KB. Tool read dikecualikan +total karena isinya harus tetap byte-exact untuk edit selanjutnya." +``` + +--- + +### Task 5: `context/shaping.rs` (port of `shortsend.rs`) + +**Files:** +- Modify: `src/app/runtime/context/mod.rs` +- Create: `src/app/runtime/context/shaping.rs` + +**Interfaces:** +- Consumes: `context::tokens::count_tokens` (Task 1). +- Produces: `pub fn context::shaping::should_shape(token_count: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool`, + `pub fn context::shaping::shape_messages(messages: &[ChatMessage], token_count: usize, max_wire_tokens: usize, force: bool, client: Option<&LlmClient>) -> Vec` + — same names and signatures as today's `shortsend::should_shape`/`shortsend::shape_messages`, + so Task 6 is a pure find-and-replace of the module path. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/runtime/context/shaping.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::chat::message::ChatMessage; + + #[test] + fn should_shape_triggers_at_85_percent_when_not_previously_shaped() { + assert!(should_shape(850, 1000, false)); + assert!(!should_shape(849, 1000, false)); + } + + #[test] + fn should_shape_uses_95_percent_threshold_once_already_shaped() { + assert!(!should_shape(900, 1000, true), "below 95% and already shaped: no re-trigger yet"); + assert!(should_shape(950, 1000, true)); + } + + #[test] + fn shape_messages_is_a_noop_under_budget_and_not_forced() { + let messages = vec![ + ChatMessage::system("sys"), + ChatMessage::user("hi"), + ChatMessage::assistant(Some("hello".to_string())), + ]; + let result = shape_messages(&messages, 10, 1000, false, None); + assert_eq!(result.len(), messages.len()); + } + + #[test] + fn shape_messages_always_preserves_the_first_system_message() { + let mut messages = vec![ChatMessage::system("system prompt")]; + for i in 0..20 { + messages.push(ChatMessage::user(format!("message {i}"))); + } + let result = shape_messages(&messages, 100_000, 1000, true, None); + assert_eq!(result[0].content.as_deref(), Some("system prompt")); + } + + #[test] + fn shape_messages_without_a_client_falls_back_to_placeholder_summary() { + let mut messages = vec![ChatMessage::system("system prompt")]; + for i in 0..20 { + messages.push(ChatMessage::user(format!("message number {i} with some padding text"))); + } + let result = shape_messages(&messages, 100_000, 1000, true, None); + let has_placeholder = result.iter().any(|m| { + m.content.as_deref() == Some("[prior conversation compacted]") + }); + assert!(has_placeholder); + } + + #[test] + fn shape_messages_keeps_most_recent_messages_over_older_ones() { + let mut messages = vec![ChatMessage::system("system prompt")]; + for i in 0..20 { + messages.push(ChatMessage::user(format!("message number {i} with some padding text"))); + } + let result = shape_messages(&messages, 100_000, 1000, true, None); + let last_content = messages.last().unwrap().content.clone(); + assert!(result.iter().any(|m| m.content == last_content), "most recent message must survive shaping"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --bin zesdex context::shaping::tests` +Expected: compile error — `should_shape`/`shape_messages` not found. + +- [ ] **Step 3: Write the implementation** + +Add above the test block (ported from `src/app/runtime/shortsend.rs`, unchanged behavior, +only the token-estimation call site changed to use `tokens::count_tokens`): + +```rust +//! Budget-based message shaping: compacts long conversation histories so +//! they fit within the provider's context window before being sent to +//! the LLM API. Ported from the former `runtime::shortsend` — behavior +//! is unchanged, only its token-counting now goes through +//! `context::tokens` instead of an inline heuristic. + +use super::tokens::count_tokens; +use crate::dto::chat::message::ChatMessage; + +/// Decide whether the message list should be shaped (compacted) before +/// sending to the LLM. +/// +/// Flow: trigger based on token estimate. If `token_estimate` exceeds +/// the threshold, we shape. When `prev_shaped` is true, the threshold is +/// raised (95%) to avoid fluttering — compaction only re-triggers when +/// the context is genuinely full again. When `prev_shaped` is false, the +/// threshold is lower (85%) so compaction starts proactively. +/// +/// Why: hysteresis prevents repeated compaction on every turn when the +/// token count hovers near the boundary. +/// +/// Return: `true` if shaping should be applied. +pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool { + let threshold = if prev_shaped { + (max_wire_tokens as f32 * 0.95) as usize + } else { + (max_wire_tokens as f32 * 0.85) as usize + }; + token_estimate >= threshold +} + +/// Compact a long message list by dropping middle messages and inserting +/// a summary placeholder. +/// +/// Flow: if the estimated token count is within budget and not forced, +/// return messages unchanged -> otherwise keep the system message and +/// the most recent messages that fit a 70%-of-budget target, with a +/// `[prior conversation compacted]` (or LLM-generated summary, if +/// `client` is `Some`) system message in between. +/// +/// Why: keeps context-size overhead roughly constant regardless of +/// session length. +/// +/// Return: the shaped message list, or `messages` unchanged if shaping +/// wasn't needed. +pub fn shape_messages( + messages: &[ChatMessage], + token_count: usize, + max_wire_tokens: usize, + force: bool, + client: Option<&crate::service::provider::LlmClient>, +) -> Vec { + if !force && (token_count <= max_wire_tokens || messages.len() < 5) { + return messages.to_vec(); + } + + let target_tokens = (max_wire_tokens as f32 * 0.70) as usize; + let mut current_tokens = 0; + let mut keep_recent = Vec::new(); + let mut dropped_msgs = Vec::new(); + + let mut msgs_to_eval = messages.to_vec(); + let first = if msgs_to_eval.is_empty() { + None + } else { + Some(msgs_to_eval.remove(0)) + }; + + for m in msgs_to_eval.into_iter().rev() { + let text = m.content.as_deref().unwrap_or(""); + let msg_tokens = count_tokens(text); + + if current_tokens + msg_tokens <= target_tokens { + current_tokens += msg_tokens; + keep_recent.push(m); + } else { + dropped_msgs.push(m); + } + } + + dropped_msgs.reverse(); + + let mut result = Vec::new(); + if let Some(f) = first { + result.push(f); + } + + if !dropped_msgs.is_empty() { + let mut summary_text = "[prior conversation compacted]".to_string(); + + if let Some(llm) = client { + let prompt = format!( + "Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}", + dropped_msgs.iter() + .map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or(""))) + .collect::>() + .join("\n\n") + ); + + let req_msgs = vec![ChatMessage::user(prompt)]; + match llm.chat_with_tools_non_streaming(&req_msgs, None) { + Ok(resp) => { + if let Some(content) = resp.0.content { + summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]"); + } + } + Err(e) => { + tracing::warn!( + "[context::shaping] LLM summarization failed: {}. \ + Prior conversation history is lost — no summary available. \ + This means the model will lose context about earlier parts of \ + the conversation.", + e, + ); + } + } + } + + result.push(ChatMessage::system(summary_text)); + } + + result.extend(keep_recent.into_iter().rev()); + result +} +``` + +In `src/app/runtime/context/mod.rs`, add `pub mod shaping;` to the module list. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --bin zesdex context::shaping::tests` +Expected: `test result: ok. 6 passed; 0 failed` + +- [ ] **Step 5: Commit** + +```bash +git add src/app/runtime/context/mod.rs src/app/runtime/context/shaping.rs +git commit -m "feat(context): tambah context::shaping (port dari shortsend) + +Perilaku should_shape/shape_messages tidak berubah, hanya sumber +penghitungan token yang sekarang lewat context::tokens (tiktoken-rs) +menggantikan heuristik char/3 bawaannya sendiri." +``` + +--- + +### Task 6: Cut over to `context::` everywhere; delete `shortsend.rs`; fix manual `/compact` + +This task is kept as one unit rather than split, because deleting `shortsend.rs` and cutting +`Action::Compact` over to `context::` are inseparable — the moment the file is deleted, +`Action::Compact`'s existing reference to `shortsend::shape_messages` stops compiling, so a +split here would leave an intermediate task that can't build (violating "each task ends with +an independently testable deliverable"). + +**Files:** +- Modify: `src/app/runtime/actions/mod.rs` (auto-loop at lines 1146-1174, `spawn_turn` at + lines 616-724, `Action::Compact` at lines 547-563, new `resolve_llm_client_config` helper) +- Modify: `src/app/runtime/mod.rs` +- Delete: `src/app/runtime/shortsend.rs` + +**Interfaces:** +- Consumes: `context::dedup::collapse` (Task 3), `context::tokens::count_message_tokens` + (Task 1), `context::shaping::{should_shape, shape_messages}` (Task 5), + `context::window::resolve` (Task 2). +- Produces: `fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option), String>` + (private, reused by both `spawn_turn` and `Action::Compact`) — no other task depends on it. + +This task has no new unit tests of its own — Tasks 1, 2, 3, and 5 already cover the +underlying logic. Verification here is that `run_agent_turn`'s existing turn-loop behavior +is preserved end-to-end, and that manual `/compact` now gets real LLM summarization like the +automatic path already does — checked by building and running the full test suite. + +- [ ] **Step 1: Replace the auto-loop's compaction block** + +In `src/app/runtime/actions/mod.rs`, replace lines 1146-1174: + +```rust + loop { + let total_chars: usize = msgs.iter() + .filter_map(|m| m.content.as_deref()) + .map(str::len) + .sum(); + let token_estimate = total_chars / 4; + let max_wire_tokens = tc.context_window; + + // Skip message compaction if abort was requested — the non-streaming + // LLM call for summarization would block without checking abort_flag. + let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) + && crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) + { + prev_shaped = true; + let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client)); + + // Dispatch the compacted messages to the main thread so the local session history + // is permanently compacted and doesn't trigger shaping again immediately on next turn. + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Compacted(compacted.clone())); + } + + // Also update our local `msgs` variable so the rest of the loop operates on the compacted version + msgs.clone_from(&compacted); + compacted + } else { + prev_shaped = false; + msgs.clone() + }; +``` + +with: + +```rust + loop { + // Dedup runs every iteration, unconditionally — repeated + // read-only tool calls (same tool + same arguments) are + // collapsed to their latest result before anything else, so + // context stays minimal from turn 1 instead of only shrinking + // once shaping's budget threshold trips. + let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs); + let token_count: usize = deduped.iter() + .map(crate::app::runtime::context::tokens::count_message_tokens) + .sum(); + let max_wire_tokens = tc.context_window; + + // Skip shaping if abort was requested — the non-streaming LLM + // call for summarization would block without checking abort_flag. + let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) + && crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped) + { + prev_shaped = true; + let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client)); + + // Dispatch to the main thread so the local session history is + // permanently updated and doesn't re-trigger shaping immediately + // on the next turn. + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Compacted(compacted.clone())); + } + + msgs.clone_from(&compacted); + compacted + } else { + prev_shaped = false; + if dedup_changed { + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Compacted(deduped.clone())); + } + msgs.clone_from(&deduped); + } + deduped + }; +``` + +- [ ] **Step 2: Switch the module registration, delete the old file (don't build yet)** + +In `src/app/runtime/mod.rs`, remove the line `pub mod shortsend;`. + +Run: `rm src/app/runtime/shortsend.rs` + +The build is expected to fail after this step (`Action::Compact` still references +`shortsend::shape_messages`) — that's fine, continue straight to Step 3 rather than running +`cargo build` here. + +- [ ] **Step 3: Extract `resolve_llm_client_config`** + +In `src/app/runtime/actions/mod.rs`, add this new private function just above `fn spawn_turn` +(around line 615): + +```rust +/// Resolve the API key, model name, and base URL for the currently +/// configured provider. +/// +/// Flow: look up the provider's `ProviderConfig` for its `api_base` -> +/// resolve the API key from `Settings.api_keys`, falling back to the +/// provider's `api_key_env` environment variable, then its +/// `default_api_key`, then the crate-wide empty-string default. +/// +/// Why: this exact resolution was duplicated between `spawn_turn` and +/// needed again for `Action::Compact`'s background-thread LLM call — +/// factored out so both stay in sync. +/// +/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a +/// user-facing string — if the configured provider has no entry in +/// `AppConfig.providers` at all. +fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option), String> { + let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone()); + let Some(base_url) = base_url else { + return Err(format!( + "Provider '{}' is not configured — no matching entry found. \ + Pick a different provider in Settings, or configure it.", + state.settings.provider + )); + }; + let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); + if api_key.is_empty() { + if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { + api_key = provider_cfg.api_key_env.as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| provider_cfg.default_api_key.clone()) + .unwrap_or_default(); + } + } + if api_key.is_empty() { + api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); + } + Ok((api_key, state.settings.model.clone(), Some(base_url))) +} +``` + +- [ ] **Step 4: Simplify `spawn_turn` to use it** + +In `spawn_turn`, replace lines 633-668 (the inline `api_key`/`model`/`base_url` resolution +and its `base_url.is_none()`/`api_key.is_empty()` checks): + +```rust + let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); + let model = state.settings.model.clone(); + let base_url = state.app_config.providers.get(&state.settings.provider) + .map(|p| p.api_base.clone()); + let context_window = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; + // The selected provider has no entry in app_config at all (e.g. the + // Claude-settings auto-detection that registers "claude" found nothing + // this run). Without this check, LlmClient::new silently falls back to + // the zen default base URL while keeping this provider's model name — + // a mismatched request that reaches a real server and comes back as a + // confusing "Missing API key" 401 from an unrelated provider, instead + // of the actual problem: the configured provider doesn't exist. + if base_url.is_none() { + if let Ok(mut q) = state.turn_events.lock() { + q.push_back(TurnEvent::Error(format!( + "Provider '{}' is not configured — no matching entry found. \ + Pick a different provider in Settings, or configure it.", + state.settings.provider + ))); + } + return; + } + if api_key.is_empty() { + if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { + api_key = provider_cfg.api_key_env.as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| provider_cfg.default_api_key.clone()) + .unwrap_or_default(); + } + } + if api_key.is_empty() { + api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); + } +``` + +with: + +```rust + let (api_key, model, base_url) = match resolve_llm_client_config(state) { + Ok(v) => v, + Err(msg) => { + if let Ok(mut q) = state.turn_events.lock() { + q.push_back(TurnEvent::Error(msg)); + } + return; + } + }; + let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); +``` + +(`base_url` is now `Option` already — `LlmClient::new(api_key, model.clone(), +base_url)` further down at what was line 699 stays exactly as written; no change needed +there since the type didn't change.) + +- [ ] **Step 5: Rewrite `Action::Compact`** + +Replace the entire `Action::Compact` arm (lines 547-563): + +```rust + Action::Compact => { + let max_wire_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; + + if let Some(ref mut rt) = state.session_runtime { + let total_chars: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(str::len) + .sum(); + let token_estimate = total_chars / 3; + rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None); + state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string())); + state.dirty = true; + } + } +``` + +with: + +```rust + Action::Compact => { + let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else { + return; + }; + if messages.is_empty() { + return; + } + let (api_key, model, base_url) = match resolve_llm_client_config(state) { + Ok(v) => v, + Err(msg) => { + state.push_toast(Toast::new(ToastKind::Error, msg)); + return; + } + }; + let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); + let turn_events = state.turn_events.clone(); + + state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string())); + + // Manual /compact previously ran synchronously and always + // passed `client: None` to shape_messages, so it never got + // LLM summarization — only automatic mid-turn compaction did. + // Running this on a background thread (same pattern as + // spawn_turn) fixes that asymmetry: both paths now summarize + // dropped history with the LLM instead of one silently + // falling back to a bare placeholder. + std::thread::spawn(move || { + let client = crate::service::provider::LlmClient::new(api_key, model, base_url); + let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages); + let token_count: usize = deduped.iter() + .map(crate::app::runtime::context::tokens::count_message_tokens) + .sum(); + let compacted = crate::app::runtime::context::shaping::shape_messages( + &deduped, token_count, max_wire_tokens, true, Some(&client), + ); + if let Ok(mut q) = turn_events.lock() { + q.push_back(TurnEvent::Compacted(compacted)); + } + }); + } +``` + +The existing `TurnEvent::Compacted` handler (lines 506-512) already sets `rt.messages`, pushes +an info toast, and marks `state.dirty = true` — no change needed there; it now serves both the +automatic and manual paths identically, which is the point of this fix. + +- [ ] **Step 6: Build and run the full test suite** + +Run: `cargo build` +Expected: builds clean — no remaining references to `crate::app::runtime::shortsend` +anywhere (`grep -rn "runtime::shortsend" src/` should now be empty). + +Run: `cargo test --bin zesdex` +Expected: all tests pass, including the new `context::*` tests from Tasks 1-5. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/runtime/actions/mod.rs src/app/runtime/mod.rs +git rm src/app/runtime/shortsend.rs +git commit -m "refactor(runtime): pindah ke context::, perbaiki asimetri /compact manual + +Loop auto-compaction sekarang selalu menjalankan dedup tiap iterasi +lalu shaping lewat context::, menggantikan shortsend:: yang dihapus. + +Action::Compact tadinya berjalan sinkron dan selalu client: None, +sehingga hasil compact manual tidak pernah diringkas LLM (beda dengan +compaction otomatis di tengah turn). Sekarang /compact jalan di +thread background seperti spawn_turn, sehingga bisa memanggil LLM +untuk meringkas riwayat yang dibuang — perilaku manual dan otomatis +jadi setara. + +Ekstrak resolve_llm_client_config() dari spawn_turn supaya logika +resolusi api_key/model/base_url tidak dua kali." +``` + +--- + +### Task 7: Wire `squash` into the tool-result construction site + +**Files:** +- Modify: `src/app/runtime/actions/mod.rs:1420` + +**Interfaces:** +- Consumes: `context::squash::apply` (Task 4). + +- [ ] **Step 1: Apply squash before wrapping the tool result** + +In `src/app/runtime/actions/mod.rs`, the tool-result loop (around line 1406-1422) sends the +**raw** `output` to the UI via `TurnEvent::ToolResult` (line 1410-1416, for the user's own +transcript view — this must stay unmodified so the user always sees the tool's real output), +then separately wraps `output` into the `ChatMessage` that becomes part of LLM-bound history +(line 1420) — that second copy is the one to compress. Replace: + +```rust + let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); + archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg); + msgs.push(tool_msg); +``` + +with: + +```rust + let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output); + let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output); + archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg); + msgs.push(tool_msg); +``` + +(`output` was already cloned into the `TurnEvent::ToolResult` push a few lines above at +`output: output.clone()`, line 1413, so the original full-size string is still available +here for `squash::apply` to read before this final `output` value is consumed.) + +- [ ] **Step 2: Build and confirm behavior** + +Run: `cargo build` +Expected: builds clean. + +Run: `cargo test --bin zesdex` +Expected: all tests still pass — this task has no new tests of its own (`squash::apply`'s +logic is already covered in Task 4); this step only confirms the wiring compiles and nothing +else broke. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/runtime/actions/mod.rs +git commit -m "feat(runtime): kompres hasil tool lewat squash sebelum masuk context + +Salinan yang dikirim ke UI (TurnEvent::ToolResult) tetap utuh; hanya +salinan yang masuk riwayat percakapan (dikirim ke LLM) yang dikompres, +supaya user tetap melihat output tool apa adanya." +``` + +--- + +### Task 8: Switch `view/status.rs` to `tokens`/`window` + +**Files:** +- Modify: `src/view/status.rs` + +**Interfaces:** +- Consumes: `context::tokens::count_tokens` (Task 1), `context::window::resolve` (Task 2). + +- [ ] **Step 1: Replace both token/window calculations** + +In `src/view/status.rs`, replace the `if let Some(ref rt) = state.session_runtime` branch +(lines 59-79): + +```rust + let right_str = if let Some(ref rt) = state.session_runtime { + let max_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window); + + let total_chars: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(str::len) + .sum(); + let current_tokens = total_chars / 4; + + let mut parts = Vec::new(); + if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { + parts.push(format!("↑{} ↓{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); + } + let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string()); + parts.push(format!("{current_tokens}/{max_str}")); + parts.push(state.settings.provider.clone()); + parts.push(state.settings.model.clone()); + + format!(" {} ", parts.join(" · ")) + } else { + let max_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window); + let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string()); + format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model) + }; +``` + +with: + +```rust + let max_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); + + let right_str = if let Some(ref rt) = state.session_runtime { + let current_tokens: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(crate::app::runtime::context::tokens::count_tokens) + .sum(); + + let mut parts = Vec::new(); + if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { + parts.push(format!("↑{} ↓{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); + } + parts.push(format!("{current_tokens}/{max_tokens}")); + parts.push(state.settings.provider.clone()); + parts.push(state.settings.model.clone()); + + format!(" {} ", parts.join(" · ")) + } else { + format!(" 0/{} · {} · {} ", max_tokens, state.settings.provider, state.settings.model) + }; +``` + +Note the behavior change: the status bar now always shows a concrete number (falling back to +`default_context_window` like compaction itself does) instead of sometimes showing `?` for a +model with no explicit `context_window` override — this makes the display consistent with +what `should_shape`/`shape_messages` actually use, closing the fourth inconsistency found +during design (the status bar's own copy of this lookup didn't fall back the same way the +other two copies did). + +- [ ] **Step 2: Build and verify** + +Run: `cargo build` +Expected: builds clean. + +Run: `cargo test --bin zesdex` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/view/status.rs +git commit -m "refactor(status): pakai context::tokens dan context::window + +Status bar sekarang memakai penghitungan token yang sama persis dengan +compaction (bukan heuristik /4 terpisah), dan selalu menampilkan angka +context window nyata alih-alih '?' saat model role tidak override +context_window secara eksplisit — konsisten dengan fallback yang +sudah dipakai compaction sendiri." +``` + +--- + +### Task 9: Concise-mode system-prompt toggle + +**Files:** +- Modify: `src/model/settings.rs` +- Modify: `src/app/runtime/actions/mod.rs:930-936` + +**Interfaces:** +- Produces: `Settings.concise_output: bool` (new field, default `false`). + +- [ ] **Step 1: Write the failing test** + +In `src/model/settings.rs`, add to the existing `#[cfg(test)] mod tests` block: + +```rust + #[test] + fn concise_output_defaults_to_false() { + assert!(!Settings::default().concise_output); + } + + #[test] + fn missing_concise_output_field_falls_back_to_default() { + // Simulates loading a settings.json written before this field + // existed — #[serde(default)] must fill it in rather than + // failing the whole parse. + let old_json = r#"{ + "internet_mode": "Off", + "provider": "zen", + "model": "deepseek-v4-flash-free", + "api_keys": {}, + "max_tokens": null, + "temperature": null, + "review_enabled": true, + "review_max_lessons_per_run": 5, + "adaptive_review_max_skip": 3, + "verify_command": null, + "verify_timeout_ms": 30000, + "workflow_max_concurrency": 5, + "session_archive_enabled": true, + "lsp_auto_provision": true, + "lsp_languages": [] + }"#; + let parsed: Settings = serde_json::from_str(old_json) + .expect("must parse even without the new field present"); + assert!(!parsed.concise_output); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --bin zesdex settings::tests::concise_output` +Expected: compile error — `concise_output` field doesn't exist on `Settings`. + +- [ ] **Step 3: Add the field** + +In `src/model/settings.rs`, add to the `Settings` struct (after `hive_mind_node_timeout_ms`): + +```rust + /// Off by default. When enabled, appends an instruction to the + /// system prompt asking the model to write tersely — drop articles, + /// filler words, hedging, and pleasantries; keep code, commands, and + /// error text byte-exact — with an explicit exception for + /// destructive-operation confirmations and security warnings, which + /// always get full detail regardless of this setting. + #[serde(default)] + pub concise_output: bool, +``` + +And in `impl Default for Settings`, add `concise_output: false,` to the struct literal. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --bin zesdex settings::tests` +Expected: `test result: ok.` — all settings tests pass, including the two new ones and the +pre-existing `hive_mind_node_timeout_ms` ones (unaffected). + +- [ ] **Step 5: Wire the system-prompt fragment** + +In `src/app/runtime/actions/mod.rs`, replace the `system_text` assembly (lines 930-936): + +```rust + let system_text = format!( + "{}\n\n{}\n\n{}{}", + crate::resources::SYSTEM_PROMPT, + crate::resources::SYSTEM_TOOLS, + tree_info, + memory_section, + ); +``` + +with: + +```rust + let concise_section = if state.settings.concise_output { + "\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\ + actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \ + Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \ + — never abbreviate or paraphrase those. Exception: for destructive-operation \ + confirmations and security-relevant warnings, always give full detail regardless of \ + this instruction — clarity matters more than brevity when something risky is at stake." + } else { + "" + }; + let system_text = format!( + "{}\n\n{}\n\n{}{}{}", + crate::resources::SYSTEM_PROMPT, + crate::resources::SYSTEM_TOOLS, + tree_info, + memory_section, + concise_section, + ); +``` + +`run_agent_turn` takes `tc: &TurnCtx`, `messages`, and `events_q` only — `state`/`Settings` +is not in scope at this call site, so the snippet above must read `tc.concise_output`, not +`state.settings.concise_output`: + +```rust + let concise_section = if tc.concise_output { +``` + +This requires threading a new field through `TurnCtx`. In `struct TurnCtx` (the definition +just after `spawn_turn`, currently ending `hive_mind_converged: bool,\n}`), add the field: + +```rust + hive_mind_converged: bool, + /// Snapshot of `Settings.concise_output` taken at the start of this + /// turn, so the system-prompt assembly above can read it without + /// `TurnCtx` needing a `Settings` reference. + concise_output: bool, +} +``` + +In `spawn_turn`, after the line added in Task 6 (`let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);`), add: + +```rust + let concise_output = state.settings.concise_output; +``` + +And in the `TurnCtx { ... }` literal built inside the `std::thread::spawn(move || { ... })` +closure (which already lists `context_window,` and `hive_mind_converged,`), add +`concise_output,` as its own line: + +```rust + context_window, + concise_output, + + workspace_roots, +``` + +- [ ] **Step 6: Build and verify** + +Run: `cargo build` +Expected: builds clean. + +Run: `cargo test --bin zesdex` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/model/settings.rs src/app/runtime/actions/mod.rs +git commit -m "feat(settings): tambah mode ringkas opsional (concise_output) + +Off by default, diaktifkan lewat settings.json (belum ada UI toggle — +sama seperti review_enabled/session_archive_enabled/lsp_auto_provision +yang juga cuma bisa diedit manual hari ini). Saat aktif, system prompt +diberi instruksi menulis ringkas, dengan pengecualian eksplisit untuk +konfirmasi operasi destruktif dan peringatan keamanan yang tetap harus +detail penuh." +``` + +--- + +## Final verification (after Task 9) + +- [ ] Run: `cargo build --release` — matches CI's "Build" step, expect success. +- [ ] Run: `cargo test` — matches CI's "Test" step, expect all tests pass (`context::*` + plus every pre-existing test in the suite, unaffected). +- [ ] Run: `grep -rn "runtime::shortsend" src/` — expect no output; the module is fully + removed and every caller migrated to `context::`. +- [ ] Run: `cargo clippy --message-format=short 2>&1 | grep -E "app/runtime/context/|actions/mod.rs|view/status.rs|model/settings.rs|subagent/division.rs"` — + expect no output (no new warnings introduced in touched/created files; pre-existing + warnings in untouched files like `view/markdown.rs`/`main.rs` are out of scope, see + Global Constraints).