Skip to main content

kranz_engine/
backend_kimi.rs

1//! Kimi Code agent backend: drives the headless `kimi -p ... --output-format
2//! stream-json` CLI.
3//!
4//! Ground truth is `crates/engine/tests/fixtures/kimi_exec_scrutiny.jsonl`
5//! and `docs/scoping/kimi-cli-backend.md` (`f-1-1` probe). Kimi's `-p`
6//! stdout wire is much thinner than Claude/Codex/Cursor's: it never emits an
7//! `init`/`system` line and never emits a dedicated terminal result frame.
8//! `backend_kimi` must *synthesize* both `Init` and the terminal `Result`:
9//!
10//! - `Init` is synthesized once the `role: "meta", type:
11//!   "session.resume_hint"` line (the sole end-of-run signal on this wire)
12//!   arrives, since that is the earliest point a session id is known.
13//! - `Result` is synthesized from that same resume-hint line, with its text
14//!   stitched from the last `role: "assistant"` line seen this run (mirrors
15//!   `backend_codex::CodexStreamParser` stitching `agent_message` text into
16//!   `turn.completed`).
17//!
18//! Token usage is not on this stdout wire at all (see docs/scoping/
19//! kimi-cli-backend.md §5); the terminal `Result`'s usage is therefore
20//! always the zero default here, and cost is computed client-side via
21//! [`cost::usage_cost_usd`]. Tool-call/tool-result wire shapes are
22//! unobserved (no tool-using capture exists yet); any unrecognized `role`
23//! value is routed to [`AgentEvent::Other`] rather than guessed at.
24//!
25//! This module is single-shot only: unlike `backend_claude`, there is no
26//! `--resume`/streaming-input mode, so [`KimiSession::send_user_message`]
27//! and a `resume`d [`SessionSpec`] are both rejected at the seam rather than
28//! translated into kimi flags.
29//!
30//! Several `SessionSpec` fields are claude-isms with no kimi equivalent and
31//! are deliberately ignored when building argv: `json_schema`,
32//! `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
33//! `disallowed_tools`, `tools`, `settings_json`. `effort` is *not* ignored:
34//! kimi has no `--effort`/`-m model:effort` flag (confirmed live in the
35//! probe), so effort is instead forwarded as the `KIMI_MODEL_THINKING_EFFORT`
36//! environment variable on the child process.
37
38use crate::backend::{
39    AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
40};
41#[cfg(unix)]
42use crate::backend_claude::kill_group;
43#[cfg(windows)]
44use crate::backend_claude::win_job;
45use crate::cost;
46use crate::error::{EngineError, Result};
47use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
48use crate::types::TokenUsage;
49use serde_json::{json, Value};
50use std::collections::VecDeque;
51use std::path::{Path, PathBuf};
52use std::process::Stdio;
53use std::sync::{Arc, Mutex};
54use tokio::process::{Child, ChildStdout};
55use tokio::task::JoinHandle;
56
57/// Max characters of captured stderr included in failure messages.
58const STDERR_TAIL_CHARS: usize = 500;
59
60/// Env var carrying the thinking-effort level to the `kimi` child process
61/// (highest-precedence effort selector per docs/scoping/kimi-cli-backend.md
62/// §4 — there is no CLI flag for it).
63const KIMI_EFFORT_ENV_VAR: &str = "KIMI_MODEL_THINKING_EFFORT";
64
65/// The ambient var a kimi session may authenticate with (injected
66/// explicitly, never via ambient inheritance).
67const KIMI_AUTH_ENV: &str = "KIMI_API_KEY";
68
69/// The minimal `.kimi-code` state seeded into a session's scratch HOME
70/// (4th-pass review): auth does NOT survive a relocated `$HOME`
71/// (docs/scoping/kimi-cli-backend.md) — the OAuth credential cache, device
72/// id, oauth state, and provider/model config all live under `~/.kimi-code`,
73/// and `KIMI_API_KEY` only authenticates an ALREADY-configured custom
74/// provider (which lives in `config.toml`). An unseeded scratch HOME leaves
75/// every kimi session with no provider at all. `sessions/` transcripts are
76/// deliberately excluded (unbounded, and per-session state).
77const KIMI_SEED_ENTRIES: &[&str] = &["credentials", "device_id", "oauth", "config.toml"];
78
79/// The cleared environment one `kimi` session spawns with (ticket
80/// `agent-env-clear`), mirroring [`crate::backend_claude`]'s seeding
81/// contract: a spec carrying a relocated scratch `HOME` (worker relocation)
82/// is used verbatim; otherwise a fresh per-session scratch HOME is seeded
83/// with [`KIMI_SEED_ENTRIES`] so provider/model/OAuth state survives.
84/// Seeding failure degrades to an empty scratch home — the session then
85/// fails auth loudly rather than silently inheriting the operator's real
86/// HOME. `KIMI_API_KEY` is injected explicitly when set (logged name-only).
87fn kimi_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
88    if spec.env.contains_key("HOME") {
89        return crate::agent_env::agent_session_env(
90            &spec.env,
91            &spec.session_id,
92            Some(KIMI_AUTH_ENV),
93        );
94    }
95    let real_home = std::env::var_os("HOME").map(PathBuf::from);
96    let scratch_root = crate::backend_claude::scratch_home_root(&spec.session_id);
97    match seed_kimi_scratch_home(&scratch_root, real_home.as_deref()) {
98        Ok(home) => {
99            tracing::info!(
100                session_id = %spec.session_id,
101                decision = "scratch-seeded",
102                "session spec carried no relocated HOME; spawning into a seeded scratch \
103                 HOME (.kimi-code minimal auth/config set)"
104            );
105            crate::agent_env::session_env_with_home(
106                &spec.env,
107                &spec.session_id,
108                Some(KIMI_AUTH_ENV),
109                &home,
110            )
111        }
112        Err(e) => {
113            tracing::warn!(
114                session_id = %spec.session_id,
115                error = %e,
116                "kimi scratch HOME seeding failed; session spawns into an empty scratch \
117                 HOME and will fail auth loudly if KIMI_API_KEY is not injected"
118            );
119            crate::agent_env::agent_session_env(&spec.env, &spec.session_id, Some(KIMI_AUTH_ENV))
120        }
121    }
122}
123
124/// Seed `<scratch_root>/home/.kimi-code` with [`KIMI_SEED_ENTRIES`], copied
125/// opaquely (bytes only, no parsing/logging of contents) from the real
126/// home's `.kimi-code` when present; a missing source yields an
127/// empty-but-present `.kimi-code`. Returns the home dir the child should
128/// get as `HOME`.
129fn seed_kimi_scratch_home(
130    scratch_root: &Path,
131    real_home: Option<&Path>,
132) -> std::io::Result<PathBuf> {
133    let home = scratch_root.join("home");
134    let kimi_dir = home.join(".kimi-code");
135    std::fs::create_dir_all(&kimi_dir)?;
136    if let Some(real_home) = real_home {
137        let source = real_home.join(".kimi-code");
138        for entry in KIMI_SEED_ENTRIES {
139            let src = source.join(entry);
140            let dst = kimi_dir.join(entry);
141            if src.is_file() {
142                std::fs::copy(&src, &dst)?;
143            } else if src.is_dir() {
144                copy_dir_recursive(&src, &dst)?;
145            }
146        }
147    }
148    Ok(home)
149}
150
151/// Opaque recursive copy (files only; symlinks and other special entries
152/// are skipped rather than followed).
153fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
154    std::fs::create_dir_all(dst)?;
155    for entry in std::fs::read_dir(src)? {
156        let entry = entry?;
157        let file_type = entry.file_type()?;
158        let target = dst.join(entry.file_name());
159        if file_type.is_dir() {
160            copy_dir_recursive(&entry.path(), &target)?;
161        } else if file_type.is_file() {
162            std::fs::copy(entry.path(), &target)?;
163        }
164    }
165    Ok(())
166}
167
168/// Serializes every test (in this module and in `orchestrator.rs`) that
169/// mutates the process-global env vars consulted by [`discover_kimi_binary`]
170/// (`KRANZ_KIMI_BIN`, `PATH`, `HOME`), since `cargo test` runs tests in
171/// parallel threads within one process and a second, independent mutex would
172/// not mutually exclude against this one (mirrors `DROID_ENV_LOCK`, but must
173/// be `pub(crate)` — unlike droid, kimi's env-mutating tests are split across
174/// two source files and both must lock the SAME mutex).
175#[cfg(test)]
176pub(crate) static KIMI_ENV_LOCK: Mutex<()> = Mutex::new(());
177
178// ---------------------------------------------------------------------------
179// Binary discovery
180// ---------------------------------------------------------------------------
181
182/// Locate a working `kimi` binary.
183///
184/// Order: `KRANZ_KIMI_BIN` env var → `configured` → `kimi` on PATH →
185/// well-known install locations, ending with the Kimi-specific
186/// `~/.kimi-code/bin/kimi` (per docs/scoping/kimi-cli-backend.md §1, this is
187/// where the real install lives on a machine that never put it on PATH).
188/// Each candidate is validated by running it with `--version`; the first one
189/// that succeeds wins. Errors list every attempt so the user can see what
190/// was tried.
191///
192/// `KRANZ_KIMI_BIN`, when set and non-empty, is an *exclusive* override: only
193/// that path is probed, and a failure is returned immediately rather than
194/// falling through to PATH or the well-known fallback locations. Naming the
195/// binary explicitly and having it not work is an error, not a reason to
196/// search elsewhere.
197pub fn discover_kimi_binary(configured: Option<&str>) -> Result<PathBuf> {
198    if let Some(env_bin) = std::env::var_os("KRANZ_KIMI_BIN") {
199        if !env_bin.is_empty() {
200            let candidate = PathBuf::from(env_bin);
201            return match probe_version(&candidate) {
202                Ok(_version) => Ok(candidate),
203                Err(why) => Err(EngineError::Config(format!(
204                    "KRANZ_KIMI_BIN points at {} which did not work: {why}",
205                    candidate.display()
206                ))),
207            };
208        }
209    }
210
211    let mut candidates: Vec<PathBuf> = Vec::new();
212    if let Some(configured) = configured {
213        candidates.push(PathBuf::from(configured));
214    }
215    // Bare names resolve through PATH (std::process handles .cmd/.exe lookup
216    // rules per-platform).
217    candidates.push(PathBuf::from("kimi"));
218    #[cfg(windows)]
219    {
220        candidates.push(PathBuf::from("kimi.cmd"));
221        candidates.push(PathBuf::from("kimi.exe"));
222    }
223    candidates.extend(fallback_candidates());
224
225    // Dedupe, preserving priority order.
226    let mut deduped: Vec<PathBuf> = Vec::new();
227    for candidate in candidates {
228        if !deduped.contains(&candidate) {
229            deduped.push(candidate);
230        }
231    }
232
233    let mut attempts: Vec<String> = Vec::new();
234    for candidate in deduped {
235        match probe_version(&candidate) {
236            Ok(_version) => return Ok(candidate),
237            Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
238        }
239    }
240    Err(EngineError::Config(format!(
241        "no working kimi binary found; tried: {}. Install the Kimi Code CLI \
242         or point kranz at it via the validatorScrutiny.kimiBinary config \
243         field or the KRANZ_KIMI_BIN environment variable.",
244        attempts.join(", ")
245    )))
246}
247
248/// Well-known install locations checked after PATH, ending with the
249/// Kimi-specific install dir (per docs/scoping/kimi-cli-backend.md §1).
250#[cfg(not(windows))]
251fn fallback_candidates() -> Vec<PathBuf> {
252    let home = std::env::var_os("HOME").map(PathBuf::from);
253    let mut out = Vec::new();
254    if let Some(home) = &home {
255        out.push(home.join(".npm-global").join("bin").join("kimi"));
256    }
257    out.push(PathBuf::from("/opt/homebrew/bin/kimi"));
258    out.push(PathBuf::from("/usr/local/bin/kimi"));
259    if let Some(home) = &home {
260        out.push(home.join(".local").join("bin").join("kimi"));
261        out.push(home.join(".kimi-code").join("bin").join("kimi"));
262    }
263    out
264}
265
266/// Well-known install locations checked after PATH (Windows).
267#[cfg(windows)]
268fn fallback_candidates() -> Vec<PathBuf> {
269    let mut out = Vec::new();
270    if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
271        for dir in [
272            profile.join("AppData").join("Roaming").join("npm"),
273            profile.join(".npm-global").join("bin"),
274            profile.join(".local").join("bin"),
275        ] {
276            for name in ["kimi.cmd", "kimi.exe", "kimi"] {
277                out.push(dir.join(name));
278            }
279        }
280        for name in ["kimi.cmd", "kimi.exe", "kimi"] {
281            out.push(profile.join(".kimi-code").join("bin").join(name));
282        }
283    }
284    out
285}
286
287/// Deadline for a `--version` probe. Generous for a healthy CLI, but bounds
288/// a hung shim on PATH so binary discovery (`kranz ready`, session spawn)
289/// can never block forever on a candidate.
290const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
291
292/// Validate a candidate by running `<candidate> --version`, draining both
293/// output pipes concurrently while enforcing [`VERSION_PROBE_TIMEOUT`].
294fn probe_version(binary: &Path) -> std::result::Result<String, String> {
295    crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
296}
297
298// ---------------------------------------------------------------------------
299// Argument construction
300// ---------------------------------------------------------------------------
301
302/// The prompt text kimi actually receives: `append_system_prompt` (if any)
303/// concatenated ahead of the prompt text — kimi has no
304/// `--append-system-prompt` flag, so the engine folds it into the single
305/// `-p` argument instead.
306fn effective_prompt(spec: &SessionSpec) -> String {
307    let prompt_text = match &spec.prompt {
308        PromptMode::SingleShot(text) => text.as_str(),
309        PromptMode::Streaming(text) => text.as_str(),
310    };
311    match &spec.append_system_prompt {
312        Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
313        _ => prompt_text.to_string(),
314    }
315}
316
317/// Build the argv (excluding the binary itself) for one session.
318///
319/// Public so tests can assert the exact CLI wire format without spawning.
320/// Deliberately ignores every claude-only `SessionSpec` field: `json_schema`,
321/// `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
322/// `disallowed_tools`, `tools`, `settings_json`. `effort` is handled
323/// separately via the `KIMI_MODEL_THINKING_EFFORT` env var (see
324/// [`effort_env_value`]) — `-m` has no `model:effort` suffix syntax
325/// (confirmed live in docs/scoping/kimi-cli-backend.md §4).
326///
327/// No permission flag is passed: kimi ≥ 0.34 rejects `--yolo`, `--auto` and
328/// `--plan` outright when combined with `-p` ("Cannot combine --prompt with
329/// --plan"), and non-interactive mode always runs the auto permission
330/// policy. The writable/read-only role distinction therefore cannot be
331/// expressed in argv — read-only roles rely on the session profile/sandbox,
332/// not the kimi command line.
333pub fn build_args(spec: &SessionSpec) -> Vec<String> {
334    vec![
335        "-p".into(),
336        effective_prompt(spec),
337        "-m".into(),
338        spec.model.clone(),
339        "--output-format".into(),
340        "stream-json".into(),
341    ]
342}
343
344/// The value to set `KIMI_MODEL_THINKING_EFFORT` to for this session, if any
345/// (docs/scoping/kimi-cli-backend.md §4: highest-precedence effort selector,
346/// forwarded straight to the provider rather than validated client-side).
347fn effort_env_value(spec: &SessionSpec) -> Option<&str> {
348    if spec.effort.is_empty() {
349        None
350    } else {
351        Some(spec.effort.as_str())
352    }
353}
354
355// ---------------------------------------------------------------------------
356// `kimi -p --output-format stream-json` line parsing
357// ---------------------------------------------------------------------------
358
359/// Parse one stdout line into zero or more [`AgentEvent`]s. Does not
360/// synthesize `Init`/terminal-text-stitching — that requires cross-line
361/// state and lives in [`KimiStreamParser`].
362///
363/// Unparseable lines become [`AgentEvent::Other`] with
364/// `raw = {"unparsed": <line>}` so nothing is ever dropped from transcripts.
365pub fn parse_kimi_line(line: &str, model: &str) -> Vec<AgentEvent> {
366    match serde_json::from_str::<Value>(line) {
367        Ok(value) => parse_kimi_value(value, model),
368        Err(_) => vec![AgentEvent::Other {
369            raw: json!({ "unparsed": line }),
370        }],
371    }
372}
373
374/// Map one parsed `kimi -p --output-format stream-json` value to events (see
375/// module docs / docs/scoping/kimi-cli-backend.md §3). Unrecognized `role`
376/// values (e.g. an as-yet-unobserved tool-call role) route to
377/// [`AgentEvent::Other`] rather than being guessed at.
378pub fn parse_kimi_value(value: Value, model: &str) -> Vec<AgentEvent> {
379    let role = value.get("role").and_then(Value::as_str).unwrap_or("");
380    match role {
381        "assistant" => {
382            let text = value.get("content").and_then(Value::as_str).unwrap_or("");
383            if text.is_empty() {
384                vec![AgentEvent::Other { raw: value }]
385            } else {
386                vec![AgentEvent::Text {
387                    text: text.to_string(),
388                    raw: value,
389                }]
390            }
391        }
392        "meta" if value.get("type").and_then(Value::as_str) == Some("session.resume_hint") => {
393            vec![parse_terminal(value, model)]
394        }
395        _ => vec![AgentEvent::Other { raw: value }],
396    }
397}
398
399/// Synthesize the terminal `Result` from a `session.resume_hint` line. No
400/// usage/cost field exists on this wire (docs/scoping/kimi-cli-backend.md
401/// §5), so usage is always the zero default and cost is always computed
402/// client-side via [`cost::usage_cost_usd`].
403fn parse_terminal(value: Value, model: &str) -> AgentEvent {
404    let usage = TokenUsage::default();
405    let cost_usd = Some(cost::usage_cost_usd(&usage, model));
406    AgentEvent::Result {
407        text: String::new(),
408        is_error: false,
409        usage,
410        cost_usd,
411        num_turns: Some(1),
412        raw: value,
413    }
414}
415
416/// Last `max` characters of `text` (for stderr tails in error messages).
417fn last_chars(text: &str, max: usize) -> String {
418    let chars: Vec<char> = text.chars().collect();
419    let start = chars.len().saturating_sub(max);
420    chars[start..].iter().collect()
421}
422
423// ---------------------------------------------------------------------------
424// Stateful stream parsing (synthesizes `Init` and stitches terminal text —
425// see module docs / docs/scoping/kimi-cli-backend.md §3)
426// ---------------------------------------------------------------------------
427
428/// Stateful wrapper around [`parse_kimi_line`] that remembers the most
429/// recent `role: "assistant"` [`AgentEvent::Text`] and, on the terminal
430/// `session.resume_hint` line, synthesizes an [`AgentEvent::Init`] (the
431/// session id first becomes known on this line) followed by the
432/// [`AgentEvent::Result`] with text stitched from the last assistant
433/// message.
434#[derive(Debug, Default)]
435pub struct KimiStreamParser {
436    last_text: Option<String>,
437    init_emitted: bool,
438}
439
440impl KimiStreamParser {
441    pub fn new() -> Self {
442        KimiStreamParser::default()
443    }
444
445    /// Parse one stdout line, synthesizing `Init` (once) and stitching
446    /// remembered assistant text into the terminal `Result`.
447    pub fn push(&mut self, line: &str, model: &str) -> Vec<AgentEvent> {
448        parse_kimi_line(line, model)
449            .into_iter()
450            .flat_map(|event| self.observe(event, model))
451            .collect()
452    }
453
454    fn observe(&mut self, event: AgentEvent, model: &str) -> Vec<AgentEvent> {
455        match event {
456            AgentEvent::Text { text, raw } => {
457                self.last_text = Some(text.clone());
458                vec![AgentEvent::Text { text, raw }]
459            }
460            AgentEvent::Result {
461                is_error,
462                usage,
463                cost_usd,
464                num_turns,
465                raw,
466                ..
467            } => {
468                let session_id = raw
469                    .get("session_id")
470                    .and_then(Value::as_str)
471                    .unwrap_or_default()
472                    .to_string();
473                let mut out = Vec::new();
474                if !self.init_emitted {
475                    self.init_emitted = true;
476                    out.push(AgentEvent::Init {
477                        session_id,
478                        model: model.to_string(),
479                        raw: raw.clone(),
480                    });
481                }
482                out.push(AgentEvent::Result {
483                    text: self.last_text.take().unwrap_or_default(),
484                    is_error,
485                    usage,
486                    cost_usd,
487                    num_turns,
488                    raw,
489                });
490                out
491            }
492            other => vec![other],
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Backend
499// ---------------------------------------------------------------------------
500
501/// The [`AgentBackend`] for `kimi -p --output-format stream-json`:
502/// single-shot; kimi ≥ 0.34 rejects every permission flag alongside `-p`,
503/// so non-interactive sessions always run kimi's auto permission policy.
504#[derive(Debug, Clone)]
505pub struct KimiBackend {
506    binary: PathBuf,
507}
508
509impl KimiBackend {
510    /// Use an explicit binary path (no validation performed).
511    pub fn new(binary: impl Into<PathBuf>) -> Self {
512        KimiBackend {
513            binary: binary.into(),
514        }
515    }
516
517    /// Discover the binary via [`discover_kimi_binary`].
518    pub fn discover(configured: Option<&str>) -> Result<Self> {
519        Ok(KimiBackend {
520            binary: discover_kimi_binary(configured)?,
521        })
522    }
523
524    /// The binary this backend spawns.
525    pub fn binary(&self) -> &Path {
526        &self.binary
527    }
528}
529
530#[async_trait::async_trait]
531impl AgentBackend for KimiBackend {
532    async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
533        if spec.resume.is_some() {
534            return Err(EngineError::Backend(
535                "kimi backend is single-shot only; resume is unsupported".to_string(),
536            ));
537        }
538        let model = spec.model.clone();
539        let args = build_args(&spec);
540
541        let mut command = tokio::process::Command::new(&self.binary);
542        command
543            .args(&args)
544            .current_dir(&spec.cwd)
545            // agent-env-clear: CLEARED env from the minimal allowlist; the
546            // scratch HOME is SEEDED with the minimal .kimi-code auth/config
547            // set (auth does not survive a relocated HOME), and the one
548            // ambient var a kimi session may authenticate with is injected
549            // explicitly, never the whole ambient set.
550            .env_clear()
551            .envs(kimi_child_env(&spec))
552            .stdin(Stdio::null())
553            .stdout(Stdio::piped())
554            .stderr(Stdio::piped())
555            .kill_on_drop(true);
556        if let Some(authority) = effort_env_value(&spec) {
557            command.env(KIMI_EFFORT_ENV_VAR, authority);
558        }
559        // Unix: make the child the leader of a fresh process group so aborts
560        // can kill the whole tree, mirroring `backend_claude::ClaudeBackend`.
561        #[cfg(unix)]
562        command.process_group(0);
563
564        let mut child = command.spawn().map_err(|e| {
565            EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
566        })?;
567
568        // Windows: kill-on-close Job Object, mirroring `backend_claude`.
569        #[cfg(windows)]
570        let job = match child.raw_handle() {
571            Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
572                Ok(job) => Some(job),
573                Err(e) => {
574                    tracing::warn!(error = %e, "failed to create Job Object for kimi child; \
575                        tree-kill on abort will be unavailable");
576                    None
577                }
578            },
579            None => None,
580        };
581
582        let stdout = child
583            .stdout
584            .take()
585            .ok_or_else(|| EngineError::Backend("kimi child has no stdout pipe".to_string()))?;
586        let stderr = child
587            .stderr
588            .take()
589            .ok_or_else(|| EngineError::Backend("kimi child has no stderr pipe".to_string()))?;
590
591        // Capture stderr concurrently so a chatty child never blocks on a
592        // full pipe and failure messages can include the tail. The stream is
593        // drained to EOF but only a bounded tail is retained — a noisy or
594        // malicious CLI must not exhaust host memory (stream_bounds).
595        let stderr_buf = Arc::new(Mutex::new(String::new()));
596        let stderr_task = {
597            let buf = Arc::clone(&stderr_buf);
598            tokio::spawn(async move {
599                let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
600                *buf.lock().expect("stderr buffer lock") = tail;
601            })
602        };
603
604        Ok(Box::new(KimiSession {
605            session_id: spec.session_id.clone(),
606            model,
607            child,
608            #[cfg(windows)]
609            job,
610            lines: BoundedLines::new(stdout),
611            stderr_buf,
612            stderr_task: Some(stderr_task),
613            queue: VecDeque::new(),
614            stream_parser: KimiStreamParser::new(),
615            saw_result: false,
616            saw_success_result: false,
617            exit: None,
618        }))
619    }
620}
621
622// ---------------------------------------------------------------------------
623// Session
624// ---------------------------------------------------------------------------
625
626/// A live `kimi -p --output-format stream-json` session (the
627/// [`AgentSession`] impl).
628///
629/// Single-shot only: [`send_user_message`](AgentSession::send_user_message)
630/// always errors, and there is no streaming stdin to hold open.
631pub struct KimiSession {
632    session_id: String,
633    model: String,
634    child: Child,
635    #[cfg(windows)]
636    job: Option<win_job::JobHandle>,
637    lines: BoundedLines<ChildStdout>,
638    stderr_buf: Arc<Mutex<String>>,
639    stderr_task: Option<JoinHandle<()>>,
640    /// Multi-block lines queue several events (an `Init`+`Result` pair on
641    /// the resume-hint line); popped one per `next_event`.
642    queue: VecDeque<AgentEvent>,
643    stream_parser: KimiStreamParser,
644    saw_result: bool,
645    saw_success_result: bool,
646    exit: Option<SessionExit>,
647}
648
649#[cfg(unix)]
650impl Drop for KimiSession {
651    fn drop(&mut self) {
652        crate::backend_claude::kill_unreaped_group(&self.child);
653    }
654}
655
656impl KimiSession {
657    fn observe(&mut self, event: &AgentEvent) {
658        match event {
659            AgentEvent::Init { session_id, .. } => {
660                self.session_id = session_id.clone();
661            }
662            AgentEvent::Result { is_error, .. } => {
663                self.saw_result = true;
664                if !is_error {
665                    self.saw_success_result = true;
666                }
667            }
668            _ => {}
669        }
670    }
671
672    /// Kill the child and reap it, best-effort; also joins the stderr capture
673    /// task. Mirrors `backend_claude::ClaudeSession::kill_child` exactly:
674    /// unix process-group SIGKILL (with a post-reap sweep for stragglers that
675    /// raced a mid-fork), windows kill-on-close Job Object.
676    async fn kill_child(&mut self) {
677        #[cfg(unix)]
678        {
679            let pgid = self
680                .child
681                .id()
682                .and_then(|pid| i32::try_from(pid).ok())
683                .filter(|pid| *pid > 0);
684            let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
685            if !group_killed {
686                let _ = self.child.start_kill();
687            }
688            let _ = self.child.wait().await;
689            if group_killed {
690                if let Some(pgid) = pgid {
691                    let _ = kill_group(pgid);
692                }
693            }
694        }
695        #[cfg(windows)]
696        {
697            match &self.job {
698                Some(job) => job.kill(),
699                None => {
700                    let _ = self.child.start_kill();
701                }
702            }
703            let _ = self.child.wait().await;
704        }
705        #[cfg(all(not(unix), not(windows)))]
706        {
707            let _ = self.child.start_kill();
708            let _ = self.child.wait().await;
709        }
710        if let Some(task) = self.stderr_task.take() {
711            let _ = task.await;
712        }
713    }
714
715    async fn finish_at_eof(&mut self) {
716        let status = self.child.wait().await;
717        if let Some(task) = self.stderr_task.take() {
718            let _ = task.await;
719        }
720        let exit = match status {
721            Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
722            Ok(status) => SessionExit::Failed(format!(
723                "kimi exited with {status}{}; stderr tail: {}",
724                if self.saw_result {
725                    ""
726                } else {
727                    " without emitting a terminal event"
728                },
729                self.stderr_tail(),
730            )),
731            Err(e) => SessionExit::Failed(format!(
732                "failed to reap kimi process: {e}; stderr tail: {}",
733                self.stderr_tail(),
734            )),
735        };
736        self.exit = Some(exit);
737    }
738
739    fn stderr_tail(&self) -> String {
740        let captured = self
741            .stderr_buf
742            .lock()
743            .map(|guard| guard.clone())
744            .unwrap_or_default();
745        last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
746    }
747}
748
749#[async_trait::async_trait]
750impl AgentSession for KimiSession {
751    fn session_id(&self) -> String {
752        self.session_id.clone()
753    }
754
755    async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
756        loop {
757            if let Some(event) = self.queue.pop_front() {
758                return Ok(Some(event));
759            }
760            if self.exit.is_some() {
761                return Ok(None);
762            }
763            let line = match self.lines.next_line().await {
764                Ok(Some(line)) => line,
765                Ok(None) => {
766                    self.finish_at_eof().await;
767                    return Ok(None);
768                }
769                Err(e) => {
770                    self.kill_child().await;
771                    self.exit = Some(SessionExit::Failed(format!(
772                        "error reading kimi stdout: {e}; stderr tail: {}",
773                        self.stderr_tail(),
774                    )));
775                    return Ok(None);
776                }
777            };
778            if line.trim().is_empty() {
779                continue;
780            }
781            let events = self.stream_parser.push(&line, &self.model);
782            for event in &events {
783                self.observe(event);
784            }
785            self.queue.extend(events);
786        }
787    }
788
789    async fn send_user_message(&mut self, _text: &str) -> Result<()> {
790        Err(EngineError::Backend(
791            "kimi backend is single-shot only; send_user_message is unsupported".to_string(),
792        ))
793    }
794
795    async fn abort(&mut self) -> Result<()> {
796        let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
797        self.kill_child().await;
798        if self.saw_success_result && already_exited {
799            self.exit = Some(SessionExit::Completed);
800        } else {
801            self.exit = Some(SessionExit::Aborted);
802        }
803        Ok(())
804    }
805
806    fn exit_status(&self) -> Option<SessionExit> {
807        self.exit.clone()
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    const TEST_MODEL: &str = "kimi-code/k3";
816
817    /// 4th-pass review: the scratch seed carries the minimal `.kimi-code`
818    /// auth/config set (auth does not survive a relocated HOME) and never
819    /// the unbounded per-session transcripts.
820    #[test]
821    fn seed_kimi_scratch_home_copies_the_minimal_auth_config_set() {
822        let real_home = tempfile::tempdir().unwrap();
823        let kimi = real_home.path().join(".kimi-code");
824        std::fs::create_dir_all(kimi.join("credentials")).unwrap();
825        std::fs::write(kimi.join("credentials").join("kimi-code.json"), "{}").unwrap();
826        std::fs::write(kimi.join("device_id"), "dev-1").unwrap();
827        std::fs::create_dir_all(kimi.join("oauth")).unwrap();
828        std::fs::write(kimi.join("oauth").join("state"), "state").unwrap();
829        std::fs::write(kimi.join("config.toml"), "model = \"kimi-code/k3\"\n").unwrap();
830        std::fs::create_dir_all(kimi.join("sessions")).unwrap();
831        std::fs::write(kimi.join("sessions").join("big.jsonl"), "transcript").unwrap();
832
833        let scratch = tempfile::tempdir().unwrap();
834        let home = seed_kimi_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
835
836        let seeded = home.join(".kimi-code");
837        assert!(seeded.join("credentials").join("kimi-code.json").is_file());
838        assert!(seeded.join("device_id").is_file());
839        assert!(seeded.join("oauth").join("state").is_file());
840        assert!(seeded.join("config.toml").is_file());
841        assert!(
842            !seeded.join("sessions").exists(),
843            "per-session transcripts are never seeded"
844        );
845    }
846
847    /// A missing real `.kimi-code` yields an empty-but-present seed (the
848    /// session then fails auth loudly rather than inheriting).
849    #[test]
850    fn seed_kimi_scratch_home_without_a_source_yields_an_empty_seed() {
851        let real_home = tempfile::tempdir().unwrap();
852        let scratch = tempfile::tempdir().unwrap();
853
854        let home = seed_kimi_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
855
856        let seeded = home.join(".kimi-code");
857        assert!(seeded.is_dir());
858        assert_eq!(std::fs::read_dir(&seeded).unwrap().count(), 0);
859    }
860
861    fn fixture_lines() -> Vec<String> {
862        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
863            .join("tests")
864            .join("fixtures")
865            .join("kimi_exec_scrutiny.jsonl");
866        std::fs::read_to_string(path)
867            .expect("read fixture")
868            .lines()
869            .filter(|line| !line.trim().is_empty())
870            .map(|line| line.to_string())
871            .collect()
872    }
873
874    #[test]
875    #[cfg(unix)]
876    fn kimi_probe_version_kills_a_hung_binary_within_the_deadline() {
877        use std::os::unix::fs::PermissionsExt;
878        let dir = tempfile::tempdir().unwrap();
879        let stub = dir.path().join("hung-kimi");
880        std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
881        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
882
883        let start = std::time::Instant::now();
884        let result = probe_version(&stub);
885
886        let error = result.expect_err("a hung probe must be reported as broken");
887        assert!(error.contains("did not exit"), "{error}");
888        assert!(
889            start.elapsed() < std::time::Duration::from_secs(10),
890            "probe returned within the deadline, not after the stub's sleep"
891        );
892    }
893
894    #[test]
895    fn kimi_stream_parser_synthesizes_init_and_terminal_result_from_fixture() {
896        let mut parser = KimiStreamParser::new();
897        let mut events: Vec<AgentEvent> = Vec::new();
898        for line in fixture_lines() {
899            events.extend(parser.push(&line, TEST_MODEL));
900        }
901
902        let init = events
903            .iter()
904            .find_map(|e| match e {
905                AgentEvent::Init { session_id, .. } => Some(session_id.clone()),
906                _ => None,
907            })
908            .expect("expected a synthesized Init event");
909        assert!(!init.is_empty(), "expected a non-empty Init session id");
910
911        let terminal = events
912            .iter()
913            .find_map(|e| match e {
914                AgentEvent::Result {
915                    text,
916                    usage,
917                    num_turns,
918                    ..
919                } => Some((text.clone(), usage.clone(), *num_turns)),
920                _ => None,
921            })
922            .expect("expected a terminal Result event");
923        let (text, usage, num_turns) = terminal;
924        assert!(
925            !text.is_empty(),
926            "expected the terminal Result text to be stitched from the last assistant line"
927        );
928        assert_eq!(
929            usage,
930            TokenUsage::default(),
931            "the fixture's terminal line carries no usage field, so usage must stay the zero \
932             default (populated iff the wire carries it)"
933        );
934        assert_eq!(num_turns, Some(1));
935    }
936
937    #[test]
938    fn kimi_discovery_honors_env_override_exclusively() {
939        // ENV_TEST_LOCK first: tempfile resolves its parent from ambient
940        // TMP/TEMP, and env-poisoning tests elsewhere in this binary (e.g.
941        // the cfg(windows) TEMP=C:\operator-tmp fixture) hold the same lock —
942        // without it a parallel windows test's poisoned TEMP makes tempdir()
943        // fail with NotFound (windows-latest CI, run 30935850957).
944        let _env_lock = crate::agent_env::ENV_TEST_LOCK
945            .lock()
946            .unwrap_or_else(|e| e.into_inner());
947        let _guard = super::KIMI_ENV_LOCK
948            .lock()
949            .unwrap_or_else(|e| e.into_inner());
950        let dir = tempfile::tempdir().unwrap();
951        let working = dir.path().join("working-kimi");
952        #[cfg(unix)]
953        {
954            use std::os::unix::fs::PermissionsExt;
955            std::fs::write(&working, "#!/bin/sh\necho kimi-code 0.27.0\n").unwrap();
956            std::fs::set_permissions(&working, std::fs::Permissions::from_mode(0o755)).unwrap();
957        }
958        let bogus = dir.path().join("does-not-exist-kimi");
959
960        std::env::set_var("KRANZ_KIMI_BIN", &bogus);
961        let result = discover_kimi_binary(Some(working.to_str().unwrap()));
962        std::env::remove_var("KRANZ_KIMI_BIN");
963
964        let error = result.expect_err("a broken KRANZ_KIMI_BIN must fail immediately");
965        assert!(
966            error.to_string().contains("KRANZ_KIMI_BIN"),
967            "expected the error to name the exclusive override, got: {error}"
968        );
969        assert!(
970            !error.to_string().contains("working-kimi"),
971            "the exclusive override must not fall through to `configured`, got: {error}"
972        );
973    }
974
975    #[test]
976    #[cfg(unix)]
977    fn kimi_discovery_falls_through_configured_to_path_then_well_known() {
978        use std::os::unix::fs::PermissionsExt;
979
980        let _guard = super::KIMI_ENV_LOCK
981            .lock()
982            .unwrap_or_else(|e| e.into_inner());
983        let saved_env_override = std::env::var_os("KRANZ_KIMI_BIN");
984        std::env::remove_var("KRANZ_KIMI_BIN");
985        let saved_path = std::env::var_os("PATH");
986        let saved_home = std::env::var_os("HOME");
987
988        let write_stub = |path: &Path| {
989            std::fs::write(path, "#!/bin/sh\necho kimi-code 0.27.0\n").unwrap();
990            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
991        };
992
993        let dir = tempfile::tempdir().unwrap();
994        let bogus_configured = dir.path().join("does-not-exist-kimi");
995
996        // Prepend (never replace) PATH so unrelated tests spawning real
997        // system binaries (`true`, `sh`, ...) concurrently on other threads
998        // keep resolving them.
999        let prepend_path = |extra: &Path| {
1000            let mut dirs = vec![extra.to_path_buf()];
1001            if let Some(existing) = std::env::var_os("PATH") {
1002                dirs.extend(std::env::split_paths(&existing));
1003            }
1004            std::env::set_var("PATH", std::env::join_paths(dirs).unwrap());
1005        };
1006
1007        // Stage 1: `configured` is broken, but a working `kimi` sits on
1008        // PATH. Discovery must fall through configured -> PATH and pick it
1009        // up (proving the `configured -> PATH` half of the ordering, not
1010        // just the exclusive-env-override branch already covered above).
1011        let path_dir = dir.path().join("path-bin");
1012        std::fs::create_dir_all(&path_dir).unwrap();
1013        let path_stub = path_dir.join("kimi");
1014        write_stub(&path_stub);
1015        prepend_path(&path_dir);
1016
1017        let path_result = discover_kimi_binary(Some(bogus_configured.to_str().unwrap()));
1018
1019        // Stage 2: PATH is pinned to a minimal, known-safe set of standard
1020        // dirs (still enough for unrelated concurrent tests to spawn `true`
1021        // / `sh`, but guaranteed to carry no `kimi`, unlike the developer's
1022        // real PATH which may well have one installed). HOME points at a
1023        // dir with a working `~/.kimi-code/bin/kimi`. Discovery must fall
1024        // through PATH -> well-known and pick it up.
1025        std::env::set_var("PATH", "/usr/bin:/bin");
1026        let home_dir = dir.path().join("home");
1027        let well_known_dir = home_dir.join(".kimi-code").join("bin");
1028        std::fs::create_dir_all(&well_known_dir).unwrap();
1029        let well_known_stub = well_known_dir.join("kimi");
1030        write_stub(&well_known_stub);
1031        std::env::set_var("HOME", &home_dir);
1032
1033        let well_known_result = discover_kimi_binary(Some(bogus_configured.to_str().unwrap()));
1034
1035        match saved_path {
1036            Some(v) => std::env::set_var("PATH", v),
1037            None => std::env::remove_var("PATH"),
1038        }
1039        match saved_home {
1040            Some(v) => std::env::set_var("HOME", v),
1041            None => std::env::remove_var("HOME"),
1042        }
1043        match saved_env_override {
1044            Some(v) => std::env::set_var("KRANZ_KIMI_BIN", v),
1045            None => std::env::remove_var("KRANZ_KIMI_BIN"),
1046        }
1047
1048        assert_eq!(
1049            path_result.expect("PATH fallback candidate should be found"),
1050            PathBuf::from("kimi"),
1051            "discovery should fall through configured -> PATH (bare name, resolved via PATH)"
1052        );
1053        assert_eq!(
1054            well_known_result.expect("well-known fallback candidate should be found"),
1055            well_known_stub,
1056            "discovery should fall through PATH -> well-known ~/.kimi-code/bin/kimi"
1057        );
1058    }
1059
1060    #[test]
1061    fn kimi_build_args_ignores_claude_only_fields_and_passes_no_permission_flag() {
1062        let spec = SessionSpec {
1063            cwd: PathBuf::from("."),
1064            prompt: PromptMode::SingleShot("do the thing".to_string()),
1065            append_system_prompt: Some("be terse".to_string()),
1066            model: "kimi-code/k3".to_string(),
1067            effort: "high".to_string(),
1068            session_id: "sess-1".to_string(),
1069            resume: None,
1070            permission_mode: Some("acceptEdits".to_string()),
1071            allowed_tools: vec!["Bash(npm test*)".to_string()],
1072            disallowed_tools: vec!["Bash(git push*)".to_string()],
1073            tools: vec!["Bash".to_string()],
1074            writable: false,
1075            settings_json: Some(json!({"hooks": {}})),
1076            json_schema: Some(json!({"type": "object"})),
1077            max_budget_usd: Some(5.0),
1078            max_turns: Some(10),
1079            env: Default::default(),
1080            sandbox: None,
1081            hook_status: None,
1082        };
1083        let args = build_args(&spec);
1084        assert_eq!(
1085            args,
1086            vec![
1087                "-p".to_string(),
1088                "be terse\n\ndo the thing".to_string(),
1089                "-m".to_string(),
1090                "kimi-code/k3".to_string(),
1091                "--output-format".to_string(),
1092                "stream-json".to_string(),
1093            ]
1094        );
1095        // kimi ≥ 0.34 rejects --plan/--yolo/--auto alongside -p outright.
1096        assert!(!args.contains(&"--plan".to_string()));
1097        assert!(!args.contains(&"--yolo".to_string()));
1098        assert_eq!(effort_env_value(&spec), Some("high"));
1099    }
1100
1101    #[test]
1102    fn kimi_build_args_writable_sessions_pass_no_permission_flag() {
1103        let spec = SessionSpec {
1104            cwd: PathBuf::from("."),
1105            prompt: PromptMode::SingleShot("do the thing".to_string()),
1106            append_system_prompt: None,
1107            model: "kimi-code/k3".to_string(),
1108            effort: String::new(),
1109            session_id: "sess-1".to_string(),
1110            resume: None,
1111            permission_mode: None,
1112            allowed_tools: vec![],
1113            disallowed_tools: vec![],
1114            tools: vec![],
1115            writable: true,
1116            settings_json: None,
1117            json_schema: None,
1118            max_budget_usd: None,
1119            max_turns: None,
1120            env: Default::default(),
1121            sandbox: None,
1122            hook_status: None,
1123        };
1124        let args = build_args(&spec);
1125        assert_eq!(
1126            args,
1127            vec![
1128                "-p".to_string(),
1129                "do the thing".to_string(),
1130                "-m".to_string(),
1131                "kimi-code/k3".to_string(),
1132                "--output-format".to_string(),
1133                "stream-json".to_string(),
1134            ]
1135        );
1136        assert!(!args.contains(&"--yolo".to_string()));
1137        assert_eq!(effort_env_value(&spec), None);
1138    }
1139
1140    #[test]
1141    fn kimi_backend_rejects_resumed_spec() {
1142        use crate::backend::AgentBackend;
1143        let backend = KimiBackend::new("kimi");
1144        let spec = SessionSpec {
1145            cwd: PathBuf::from("."),
1146            prompt: PromptMode::SingleShot("do the thing".to_string()),
1147            append_system_prompt: None,
1148            model: TEST_MODEL.to_string(),
1149            effort: "high".to_string(),
1150            session_id: "sess-1".to_string(),
1151            resume: Some("sess-0".to_string()),
1152            permission_mode: None,
1153            allowed_tools: vec![],
1154            disallowed_tools: vec![],
1155            tools: vec![],
1156            writable: false,
1157            settings_json: None,
1158            json_schema: None,
1159            max_budget_usd: None,
1160            max_turns: None,
1161            env: Default::default(),
1162            sandbox: None,
1163            hook_status: None,
1164        };
1165        let result = tokio::runtime::Builder::new_current_thread()
1166            .enable_all()
1167            .build()
1168            .unwrap()
1169            .block_on(backend.start(spec));
1170        assert!(result.is_err(), "expected resume to be rejected");
1171    }
1172
1173    #[tokio::test]
1174    async fn kimi_session_rejects_send_user_message() {
1175        // A process that exits 0 immediately. `true` is a POSIX binary with no
1176        // cmd.exe builtin and no Windows executable, so it only resolves where
1177        // Git's `usr/bin` happens to be on PATH (hosted CI, not a stock
1178        // Windows box). Spawn the platform's own no-op instead.
1179        let (program, args): (&str, &[&str]) = if cfg!(windows) {
1180            ("cmd", &["/C", "exit 0"])
1181        } else {
1182            ("true", &[])
1183        };
1184        let mut child = tokio::process::Command::new(program)
1185            .args(args)
1186            .stdin(Stdio::null())
1187            .stdout(Stdio::piped())
1188            .stderr(Stdio::piped())
1189            .kill_on_drop(true)
1190            .spawn()
1191            .expect("spawn `true`");
1192        let stdout = child.stdout.take().expect("stdout pipe");
1193        let mut session = KimiSession {
1194            session_id: "sess-1".to_string(),
1195            model: TEST_MODEL.to_string(),
1196            lines: BoundedLines::new(stdout),
1197            #[cfg(windows)]
1198            job: None,
1199            stderr_buf: Arc::new(Mutex::new(String::new())),
1200            stderr_task: None,
1201            queue: VecDeque::new(),
1202            stream_parser: KimiStreamParser::new(),
1203            saw_result: false,
1204            saw_success_result: false,
1205            exit: None,
1206            child,
1207        };
1208        let result = session.send_user_message("nope").await;
1209        assert!(result.is_err(), "expected send_user_message to be rejected");
1210    }
1211}