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