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)]
175pub(crate) static KIMI_ENV_LOCK: Mutex<()> = Mutex::new(());
176
177pub 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 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 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#[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#[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
286const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
290
291fn probe_version(binary: &Path) -> std::result::Result<String, String> {
294 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
295}
296
297fn 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
316pub 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
343fn 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
354pub 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
373pub 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
398fn 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
415fn 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#[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 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#[derive(Debug, Clone)]
504pub struct KimiBackend {
505 binary: PathBuf,
506}
507
508impl KimiBackend {
509 pub fn new(binary: impl Into<PathBuf>) -> Self {
511 KimiBackend {
512 binary: binary.into(),
513 }
514 }
515
516 pub fn discover(configured: Option<&str>) -> Result<Self> {
518 Ok(KimiBackend {
519 binary: discover_kimi_binary(configured)?,
520 })
521 }
522
523 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 .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 #[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 #[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 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
621pub 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 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 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 #[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 #[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 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 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 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 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 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 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 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}