1use 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
57const STDERR_TAIL_CHARS: usize = 500;
59
60const KIMI_EFFORT_ENV_VAR: &str = "KIMI_MODEL_THINKING_EFFORT";
64
65const KIMI_AUTH_ENV: &str = "KIMI_API_KEY";
68
69const KIMI_SEED_ENTRIES: &[&str] = &["credentials", "device_id", "oauth", "config.toml"];
78
79fn 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
124fn 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
151fn 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#[cfg(test)]
176pub(crate) static KIMI_ENV_LOCK: Mutex<()> = Mutex::new(());
177
178pub 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 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 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#[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#[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
287const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
291
292fn probe_version(binary: &Path) -> std::result::Result<String, String> {
295 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
296}
297
298fn 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
317pub 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
344fn 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
355pub 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
374pub 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
399fn 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
416fn 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#[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 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#[derive(Debug, Clone)]
505pub struct KimiBackend {
506 binary: PathBuf,
507}
508
509impl KimiBackend {
510 pub fn new(binary: impl Into<PathBuf>) -> Self {
512 KimiBackend {
513 binary: binary.into(),
514 }
515 }
516
517 pub fn discover(configured: Option<&str>) -> Result<Self> {
519 Ok(KimiBackend {
520 binary: discover_kimi_binary(configured)?,
521 })
522 }
523
524 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 .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 #[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 #[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 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
622pub 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 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 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 #[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 #[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 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 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 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 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 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 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}