1use std::collections::HashMap;
2use std::io::{self, BufRead, IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{mpsc, Arc, Condvar, Mutex};
6
7use serde::Deserialize;
8use serde_json::{Map, Value};
9
10use crate::cancellation::CancellationToken;
11use crate::config::{Config, DEFAULT_API_KEY_ENV};
12use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
13use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
14use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
15use crate::provider::{Provider, ProviderStreamEvent, ProviderTurn};
16use crate::redaction::{
17 conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
18 redaction_marker,
19};
20use crate::session::{
21 BackgroundResultDelivery, BackgroundResultPending, ChildSession, ChildSessionStatus, Session,
22};
23
24#[derive(Debug)]
25struct CliOptions {
26 session: Option<String>,
27 list_sessions: bool,
28 jsonl: bool,
29 tui: bool,
30 version: bool,
31}
32
33#[derive(Debug, Deserialize)]
34struct InputRecord {
35 #[serde(rename = "type")]
36 record_type: String,
37 text: Option<String>,
38}
39
40const MAX_CONCURRENT_SUBAGENTS: usize = 4;
41const MAX_SUBAGENT_TASK_BYTES: usize = 64 * 1024;
42const USER_CANCEL_REASON: &str = "user_cancelled";
43const PROVIDER_PHASE: &str = "provider_stream";
44const COMMAND_PHASE: &str = "cmd";
45const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
46const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
47const COMPACTION_SYSTEM_PROMPT: &str = "You are compacting a coding-agent conversation. Produce a concise, factual continuation summary. Preserve the user's goals, explicit decisions, constraints, files and code changes, commands and results, current implementation state, unresolved work, and exact identifiers that future turns need. Do not invent facts. Return only the summary text; do not call tools.";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum FrontendMode {
51 Jsonl,
52 Tui,
53}
54
55pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
56where
57 R: BufRead + Send + 'static,
58 W: Write,
59 E: Write,
60{
61 let options = match parse_args(args) {
62 Ok(options) => options,
63 Err(error) => {
64 let mut diagnostics = diagnostics;
65 write_diagnostic(&mut diagnostics, &error);
66 return 2;
67 }
68 };
69 if options.version {
70 if let Err(error) = write_version(output) {
71 let mut diagnostics = diagnostics;
72 write_diagnostic(
73 &mut diagnostics,
74 &format!("unable to write version: {error}"),
75 );
76 return 1;
77 }
78 return 0;
79 }
80
81 let home = match home_directory() {
82 Ok(home) => home,
83 Err(error) => {
84 let mut diagnostics = diagnostics;
85 write_diagnostic(&mut diagnostics, &error);
86 return 1;
87 }
88 };
89 let cwd = match std::env::current_dir() {
90 Ok(cwd) => cwd,
91 Err(_error) => {
92 let mut diagnostics = diagnostics;
93 write_diagnostic(&mut diagnostics, "unable to resolve cwd");
94 return 1;
95 }
96 };
97 run_cli_at_home_with_terminals(
98 args,
99 input,
100 output,
101 diagnostics,
102 &home,
103 &cwd,
104 io::stdin().is_terminal(),
105 io::stdout().is_terminal(),
106 )
107}
108
109pub fn run_cli_at_home<R, W, E>(
110 args: &[String],
111 input: R,
112 output: W,
113 diagnostics: E,
114 home: &Path,
115 cwd: &Path,
116) -> i32
117where
118 R: BufRead + Send + 'static,
119 W: Write,
120 E: Write,
121{
122 run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
125}
126
127#[allow(clippy::too_many_arguments)]
128fn run_cli_at_home_with_terminals<R, W, E>(
129 args: &[String],
130 input: R,
131 output: W,
132 mut diagnostics: E,
133 home: &Path,
134 cwd: &Path,
135 stdin_is_tty: bool,
136 stdout_is_tty: bool,
137) -> i32
138where
139 R: BufRead + Send + 'static,
140 W: Write,
141 E: Write,
142{
143 let options = match parse_args(args) {
144 Ok(options) => options,
145 Err(error) => {
146 let mut diagnostics = diagnostics;
147 write_diagnostic(&mut diagnostics, &error);
148 return 2;
149 }
150 };
151 if options.version {
152 if let Err(error) = write_version(output) {
153 write_diagnostic(
154 &mut diagnostics,
155 &format!("unable to write version: {error}"),
156 );
157 return 1;
158 }
159 return 0;
160 }
161 let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
162 Ok(mode) => mode,
163 Err(error) => {
164 write_diagnostic(&mut diagnostics, &error);
165 return 2;
166 }
167 };
168
169 if options.list_sessions {
170 let mut protocol = ProtocolWriter::new(output);
171 if let Err(error) = Config::ensure_exists(home) {
172 write_diagnostic(&mut diagnostics, &error.to_string());
173 return 1;
174 }
175 return match Session::list(home) {
176 Ok(sessions) => {
177 for session in sessions {
178 if let Err(error) = protocol.emit_serializable(&session) {
179 write_diagnostic(
180 &mut diagnostics,
181 &format!("unable to write session metadata: {error}"),
182 );
183 return 1;
184 }
185 }
186 0
187 }
188 Err(error) => {
189 write_diagnostic(&mut diagnostics, &error.to_string());
190 1
191 }
192 };
193 }
194
195 let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
196 {
197 let mut session = match Session::resume(home, id) {
198 Ok(session) => session,
199 Err(error) => {
200 write_diagnostic(&mut diagnostics, &error.to_string());
201 return 1;
202 }
203 };
204 let config = match Config::load_or_create(home) {
205 Ok(config) => config,
206 Err(error) => {
207 write_diagnostic(&mut diagnostics, &error.to_string());
208 return 1;
209 }
210 };
211 let selected = match config.resolved_llm() {
212 Ok(settings) => settings,
213 Err(error) => {
214 write_diagnostic_safe(
215 &mut diagnostics,
216 &error.to_string(),
217 configured_api_key(&config).as_deref(),
218 );
219 return 1;
220 }
221 };
222 session.llm.model = selected.model;
223 session.llm.effort = selected.effort;
224 let provider = match Provider::new(&session.llm) {
225 Ok(provider) => provider,
226 Err(error) => {
227 write_diagnostic(&mut diagnostics, &error.to_string());
228 return 1;
229 }
230 };
231 if let Err(error) =
232 session.append_provider_settings(session.llm.model.clone(), session.llm.effort.clone())
233 {
234 write_diagnostic_safe(
235 &mut diagnostics,
236 &error.to_string(),
237 Some(provider.api_key()),
238 );
239 return 1;
240 }
241 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
242 write_diagnostic_safe(
243 &mut diagnostics,
244 "API key conflicts with terminal UI literals",
245 Some(provider.api_key()),
246 );
247 return 1;
248 }
249 (session, provider, true, Vec::new())
250 } else {
251 let config = match Config::load_or_create(home) {
252 Ok(config) => config,
253 Err(error) => {
254 write_diagnostic(&mut diagnostics, &error.to_string());
255 return 1;
256 }
257 };
258 let configured_secret = configured_api_key(&config);
259 let api_key_env = configured_api_key_env(&config);
260 let llm = match config.resolved_llm() {
261 Ok(llm) => llm,
262 Err(error) => {
263 write_diagnostic_safe(
264 &mut diagnostics,
265 &error.to_string(),
266 configured_secret.as_deref(),
267 );
268 return 1;
269 }
270 };
271 let provider = match Provider::new(&llm) {
272 Ok(provider) => provider,
273 Err(error) => {
274 write_diagnostic_safe(
275 &mut diagnostics,
276 &error.to_string(),
277 configured_secret.as_deref(),
278 );
279 return 1;
280 }
281 };
282 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
283 write_diagnostic_safe(
284 &mut diagnostics,
285 "API key conflicts with terminal UI literals",
286 Some(provider.api_key()),
287 );
288 return 1;
289 }
290 let safe_cwd = match std::fs::canonicalize(cwd) {
291 Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
292 Ok(_) => {
293 write_diagnostic_safe(
294 &mut diagnostics,
295 "session header rejected",
296 Some(provider.api_key()),
297 );
298 return 1;
299 }
300 Err(_) => {
301 write_diagnostic_safe(
302 &mut diagnostics,
303 "unable to resolve session cwd",
304 Some(provider.api_key()),
305 );
306 return 1;
307 }
308 };
309 let context = match resolve_boot_context_with_api_key_env(
310 home,
311 &safe_cwd,
312 &config.system_prompt,
313 api_key_env.as_deref(),
314 ) {
315 Ok(context) => context,
316 Err(error) => {
317 write_diagnostic_safe(
318 &mut diagnostics,
319 &error.to_string(),
320 configured_secret.as_deref(),
321 );
322 return 1;
323 }
324 };
325 let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
326 let attached_agents = attached_agents(context.instruction_files, provider.api_key());
327 let skills = redact_skills(context.skills, provider.api_key());
328 let session = match Session::create_with_skills_and_secret(
329 home,
330 &safe_cwd,
331 boot_system_prompt,
332 llm,
333 skills,
334 Some(provider.api_key()),
335 ) {
336 Ok(session) => session,
337 Err(error) => {
338 write_diagnostic_safe(
339 &mut diagnostics,
340 &error.to_string(),
341 Some(provider.api_key()),
342 );
343 return 1;
344 }
345 };
346 (session, provider, false, attached_agents)
347 };
348
349 let harness = Harness {
350 home: home.to_path_buf(),
351 session,
352 provider,
353 context_window: None,
354 attached_agents,
355 subagents: Arc::new(Mutex::new(HashMap::new())),
356 completed_subagents: mpsc::channel(),
357 subagent_activity: mpsc::channel(),
358 };
359 if mode == FrontendMode::Tui {
360 return match crate::tui::run(harness, resumed, output) {
361 Ok(()) => 0,
362 Err(error) => {
363 write_diagnostic(&mut diagnostics, &error);
364 1
365 }
366 };
367 }
368
369 let mut protocol = ProtocolWriter::new(output);
370 let mut harness = harness;
371 if let Err(error) = protocol.session(&harness.session.id, resumed) {
372 write_diagnostic_safe(
373 &mut diagnostics,
374 &format!("unable to write session event: {error}"),
375 Some(harness.provider.api_key()),
376 );
377 return 1;
378 }
379
380 let (input_tx, input_rx) = mpsc::channel();
381 std::thread::spawn(move || {
382 for line in input.lines() {
383 if input_tx.send(line).is_err() {
384 break;
385 }
386 }
387 });
388 let mut input_closed = false;
389 loop {
390 while harness.next_subagent_activity().is_some() {}
391 if let Err(error) = harness.collect_completed_subagents(&mut protocol) {
392 let error = redact_secret(&error, Some(harness.provider.api_key()));
393 let _ = protocol.error(&error);
394 }
395 if input_closed && !harness.has_running_subagents() {
396 if let Err(error) = harness.collect_completed_subagents(&mut protocol) {
399 let error = redact_secret(&error, Some(harness.provider.api_key()));
400 let _ = protocol.error(&error);
401 }
402 break;
403 }
404 let line = match input_rx.recv_timeout(std::time::Duration::from_millis(25)) {
405 Ok(Ok(line)) => line,
406 Ok(Err(error)) => {
407 write_diagnostic_safe(
408 &mut diagnostics,
409 &format!("unable to read stdin: {error}"),
410 Some(harness.provider.api_key()),
411 );
412 return 1;
413 }
414 Err(mpsc::RecvTimeoutError::Timeout) => continue,
415 Err(mpsc::RecvTimeoutError::Disconnected) => {
416 input_closed = true;
417 continue;
418 }
419 };
420 if line.trim().is_empty() {
421 continue;
422 }
423 let text = match parse_input_message(&line) {
424 Ok(text) => text,
425 Err(error) => {
426 let error = redact_secret(&error, Some(harness.provider.api_key()));
427 if let Err(write_error) = protocol.error(&error) {
428 write_diagnostic_safe(
429 &mut diagnostics,
430 &format!("unable to write protocol error: {write_error}"),
431 Some(harness.provider.api_key()),
432 );
433 return 1;
434 }
435 continue;
436 }
437 };
438 if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
439 let error = redact_secret(&error, Some(harness.provider.api_key()));
440 if let Err(write_error) = protocol.error(&error) {
441 write_diagnostic_safe(
442 &mut diagnostics,
443 &format!("unable to write protocol error: {write_error}"),
444 Some(harness.provider.api_key()),
445 );
446 return 1;
447 }
448 }
449 }
450 0
451}
452
453pub fn resolve_mode(
454 args: &[String],
455 stdin_is_tty: bool,
456 stdout_is_tty: bool,
457) -> Result<FrontendMode, String> {
458 let options = parse_args(args)?;
459 if options.list_sessions {
460 if options.tui {
461 return Err("--tui cannot be combined with --list-sessions".to_owned());
462 }
463 return Ok(FrontendMode::Jsonl);
464 }
465 if options.tui && !(stdin_is_tty && stdout_is_tty) {
466 return Err("--tui requires a terminal on stdin and stdout".to_owned());
467 }
468 if options.tui {
469 Ok(FrontendMode::Tui)
470 } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
471 Ok(FrontendMode::Jsonl)
472 } else {
473 Ok(FrontendMode::Tui)
474 }
475}
476
477pub(crate) struct Harness {
478 pub(crate) home: PathBuf,
479 pub(crate) session: Session,
480 pub(crate) provider: Provider,
481 pub(crate) context_window: Option<usize>,
485 pub(crate) attached_agents: Vec<String>,
488 subagents: Arc<Mutex<HashMap<String, SubagentState>>>,
489 completed_subagents: (
490 mpsc::Sender<SubagentCompletion>,
491 mpsc::Receiver<SubagentCompletion>,
492 ),
493 subagent_activity: (
494 mpsc::Sender<SubagentActivity>,
495 mpsc::Receiver<SubagentActivity>,
496 ),
497}
498
499impl Drop for Harness {
500 fn drop(&mut self) {
501 let controls = self
502 .subagents
503 .lock()
504 .ok()
505 .map(|states| {
506 states
507 .values()
508 .filter_map(|state| match state {
509 SubagentState::Running { control, .. } => Some(control.clone()),
510 SubagentState::Completed(_) => None,
511 })
512 .collect::<Vec<_>>()
513 })
514 .unwrap_or_default();
515 for control in &controls {
516 control.shutdown.store(true, Ordering::Release);
517 control.cancellation.cancel();
518 }
519 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
520 for control in controls {
521 let (result_slot, wake) = &*control.done;
522 let Ok(result) = result_slot.lock() else {
523 continue;
524 };
525 if result.is_some() {
526 continue;
527 }
528 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
529 let _ = wake.wait_timeout(result, remaining);
530 }
531 while let Ok(completion) = self.completed_subagents.1.try_recv() {
532 let _ = self
533 .session
534 .append_background_result_pending(pending_from_completion(completion));
535 }
536 }
537}
538
539#[derive(Debug)]
540enum SubagentCommand {
541 Message(String),
542}
543
544#[derive(Clone)]
545struct SubagentControl {
546 cancellation: CancellationToken,
547 commands: mpsc::Sender<SubagentCommand>,
548 done: Arc<(Mutex<Option<Value>>, Condvar)>,
549 shutdown: Arc<AtomicBool>,
550}
551
552#[derive(Clone)]
553enum SubagentState {
554 Running {
555 control: SubagentControl,
556 attached_turn_id: String,
557 },
558 Completed(Value),
559}
560
561#[derive(Debug, Clone)]
562pub(crate) struct SubagentCompletion {
563 pub(crate) completion_id: String,
564 pub(crate) task_id: String,
565 pub(crate) child_session_id: String,
566 pub(crate) task: String,
567 pub(crate) status: ChildSessionStatus,
568 pub(crate) result: Value,
569 pub(crate) completed_at: u64,
570}
571
572#[derive(Debug, Clone)]
576pub(crate) enum SubagentActivity {
577 Event {
578 task_id: String,
579 event: ProtocolEvent,
580 },
581 ReasoningStarted {
582 task_id: String,
583 },
584 ReasoningCompleted {
585 task_id: String,
586 },
587}
588
589fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
590 context_window > 0
591 && context_tokens as u128 * 100
592 >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
593}
594
595fn find_compaction_boundary(
596 messages: &[ChatMessage],
597 previous_boundary: Option<usize>,
598) -> Option<usize> {
599 let user_starts = messages
600 .iter()
601 .enumerate()
602 .filter_map(|(index, message)| (message.role == "user").then_some(index))
603 .collect::<Vec<_>>();
604 let mut start = *user_starts.last()?;
605 let end = messages.len();
606 let mut kept_tokens = messages[start..end]
607 .iter()
608 .map(estimate_message_tokens)
609 .sum::<usize>();
610
611 while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
612 let Some(previous_start) = user_starts
613 .iter()
614 .copied()
615 .rev()
616 .find(|candidate| *candidate < start)
617 else {
618 break;
619 };
620 start = previous_start;
621 kept_tokens = messages[start..end]
622 .iter()
623 .map(estimate_message_tokens)
624 .sum::<usize>();
625 }
626
627 (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
628}
629
630impl Harness {
631 pub(crate) fn next_subagent_completion(&mut self) -> Option<SubagentCompletion> {
632 self.completed_subagents.1.try_recv().ok()
633 }
634
635 pub(crate) fn next_subagent_activity(&mut self) -> Option<SubagentActivity> {
636 self.subagent_activity.1.try_recv().ok()
637 }
638
639 pub(crate) fn take_subagent_activity_receiver(&mut self) -> mpsc::Receiver<SubagentActivity> {
643 let (_, replacement) = mpsc::channel();
644 std::mem::replace(&mut self.subagent_activity.1, replacement)
645 }
646
647 fn has_running_subagents(&self) -> bool {
648 self.subagents.lock().is_ok_and(|states| {
649 states
650 .values()
651 .any(|state| matches!(state, SubagentState::Running { .. }))
652 })
653 }
654
655 fn spawn_subagent(&self, task: String, logical_turn_id: &str) -> Value {
656 let under_limit = self.subagents.lock().ok().is_some_and(|states| {
657 states
658 .values()
659 .filter(|state| matches!(state, SubagentState::Running { .. }))
660 .count()
661 < MAX_CONCURRENT_SUBAGENTS
662 });
663 if !under_limit {
664 return serde_json::json!({"error": format!("subagent concurrency limit is {MAX_CONCURRENT_SUBAGENTS}")});
665 }
666 let settings = self.session.llm.clone();
667 let boot = self.session.boot_system_prompt.clone();
668 let cwd = self.session.cwd.clone();
669 let secret = self.provider.api_key().to_owned();
670 let child_session = match ChildSession::create(
671 &self.home,
672 &self.session.id,
673 &cwd,
674 boot.clone(),
675 settings.clone(),
676 task.clone(),
677 Some(&secret),
678 ) {
679 Ok(session) => session,
680 Err(error) => return serde_json::json!({"error": error.to_string()}),
681 };
682
683 let (commands, command_rx) = mpsc::channel();
684 let cancellation = CancellationToken::new();
685 let done = Arc::new((Mutex::new(None), Condvar::new()));
686 let shutdown = Arc::new(AtomicBool::new(false));
687 let control = SubagentControl {
688 cancellation: cancellation.clone(),
689 commands,
690 done: Arc::clone(&done),
691 shutdown: Arc::clone(&shutdown),
692 };
693 let task_id = child_session.id.clone();
694 {
695 let mut subagents = match self.subagents.lock() {
696 Ok(subagents) => subagents,
697 Err(_) => return serde_json::json!({"error": "subagent registry unavailable"}),
698 };
699 let running = subagents
700 .values()
701 .filter(|state| matches!(state, SubagentState::Running { .. }))
702 .count();
703 if running >= MAX_CONCURRENT_SUBAGENTS {
704 return serde_json::json!({"error": format!("subagent concurrency limit is {MAX_CONCURRENT_SUBAGENTS}")});
705 }
706 subagents.insert(
707 task_id.clone(),
708 SubagentState::Running {
709 control: control.clone(),
710 attached_turn_id: logical_turn_id.to_owned(),
711 },
712 );
713 }
714
715 let child_session_id = child_session.id.clone();
716 let states = Arc::clone(&self.subagents);
717 let completed = self.completed_subagents.0.clone();
718 let activity = self.subagent_activity.0.clone();
719 let completion_task_id = task_id.clone();
720 let activity_id = task_id.clone();
721 let completion_task = task.clone();
722 std::thread::spawn(move || {
723 let mut child_session = child_session;
724 let raw_result = run_subagent(
725 settings,
726 boot,
727 cwd,
728 task,
729 SubagentRunOptions {
730 cancellation: Some(cancellation),
731 commands: command_rx,
732 shutdown,
733 activity: Some((activity_id, activity)),
734 },
735 &mut child_session,
736 );
737 let result = redact_json_value(raw_result, &secret);
738 let status = if result.get("interrupted").is_some() {
739 ChildSessionStatus::Interrupted
740 } else if result.get("cancelled").is_some() {
741 ChildSessionStatus::Canceled
742 } else if result.get("error").is_some() {
743 ChildSessionStatus::Failed
744 } else {
745 ChildSessionStatus::Completed
746 };
747 let reason = result
748 .get("reason")
749 .and_then(Value::as_str)
750 .map(str::to_owned);
751 let _ = child_session.append_status(status, reason, Some(result.clone()));
752 let completion = SubagentCompletion {
753 completion_id: completion_id_for_child(&child_session_id),
754 task_id: completion_task_id.clone(),
755 child_session_id,
756 task: completion_task,
757 status,
758 result: result.clone(),
759 completed_at: unix_timestamp(),
760 };
761 let _ = completed.send(completion);
762 if let Ok(mut states) = states.lock() {
763 states.insert(
764 completion_task_id.clone(),
765 SubagentState::Completed(result.clone()),
766 );
767 }
768 let (result_slot, wake) = &*done;
769 if let Ok(mut slot) = result_slot.lock() {
770 *slot = Some(result);
771 wake.notify_all();
772 }
773 });
774 serde_json::json!({"task_id": task_id, "status": "queued"})
775 }
776
777 fn persist_completion<S: EventSink>(
778 &mut self,
779 completion: SubagentCompletion,
780 sink: &mut S,
781 ) -> Result<(), String> {
782 let pending = pending_from_completion(completion.clone());
783 if self
784 .session
785 .append_background_result_pending(pending)
786 .map_err(|error| error.to_string())?
787 {
788 sink.emit_event(&ProtocolEvent::BackgroundResultPending {
789 completion_id: completion.completion_id,
790 task_id: completion.task_id,
791 child_session_id: completion.child_session_id,
792 status: child_status_name(completion.status).to_owned(),
793 result: completion.result,
794 completed_at: completion.completed_at,
795 })
796 .map_err(|error| format!("unable to emit pending background result: {error}"))?;
797 }
798 Ok(())
799 }
800
801 pub(crate) fn collect_completed_subagents<S: EventSink>(
802 &mut self,
803 sink: &mut S,
804 ) -> Result<usize, String> {
805 let mut count = 0;
806 while let Some(completion) = self.next_subagent_completion() {
807 self.persist_completion(completion, sink)?;
808 count += 1;
809 }
810 Ok(count)
811 }
812
813 fn deliver_pending_background_results<S: EventSink>(
814 &mut self,
815 logical_turn_id: &str,
816 sink: &mut S,
817 ) -> Result<usize, String> {
818 let pending = self.session.undelivered_background_results();
819 let mut delivered_count = 0;
820 for result in pending {
821 if self
822 .session
823 .append_background_result_delivered(
824 &result.completion_id,
825 logical_turn_id.to_owned(),
826 BackgroundResultDelivery::Synthetic,
827 )
828 .map_err(|error| error.to_string())?
829 {
830 sink.emit_event(&ProtocolEvent::BackgroundResultDelivered {
831 completion_id: result.completion_id,
832 task_id: result.task_id,
833 logical_turn_id: logical_turn_id.to_owned(),
834 delivery: "synthetic".to_owned(),
835 })
836 .map_err(|error| format!("unable to emit delivered background result: {error}"))?;
837 delivered_count += 1;
838 }
839 }
840 Ok(delivered_count)
841 }
842
843 fn mark_wait_delivery<S: EventSink>(
844 &mut self,
845 task_id: &str,
846 logical_turn_id: &str,
847 sink: &mut S,
848 ) -> Result<(), String> {
849 self.collect_completed_subagents(sink)?;
850 let pending = self
851 .session
852 .undelivered_background_results()
853 .into_iter()
854 .find(|pending| pending.task_id == task_id);
855 let Some(pending) = pending else {
856 return Ok(());
857 };
858 if self
859 .session
860 .append_background_result_delivered(
861 &pending.completion_id,
862 logical_turn_id.to_owned(),
863 BackgroundResultDelivery::WaitSubagent,
864 )
865 .map_err(|error| error.to_string())?
866 {
867 sink.emit_event(&ProtocolEvent::BackgroundResultDelivered {
868 completion_id: pending.completion_id,
869 task_id: pending.task_id,
870 logical_turn_id: logical_turn_id.to_owned(),
871 delivery: "wait_subagent".to_owned(),
872 })
873 .map_err(|error| format!("unable to emit delivered background result: {error}"))?;
874 }
875 Ok(())
876 }
877
878 fn has_running_subagents_for_turn(&self, logical_turn_id: &str) -> bool {
879 self.subagents.lock().is_ok_and(|states| {
880 states.values().any(|state| {
881 matches!(
882 state,
883 SubagentState::Running { attached_turn_id, .. }
884 if attached_turn_id == logical_turn_id
885 )
886 })
887 })
888 }
889
890 fn wait_for_attached_completion<S: EventSink>(
891 &mut self,
892 logical_turn_id: &str,
893 sink: &mut S,
894 cancellation: Option<&CancellationToken>,
895 ) -> Result<(), String> {
896 while self.has_running_subagents_for_turn(logical_turn_id) {
897 if cancellation.is_some_and(CancellationToken::is_cancelled) {
898 return Ok(());
899 }
900 match self
901 .completed_subagents
902 .1
903 .recv_timeout(std::time::Duration::from_millis(25))
904 {
905 Ok(completion) => {
906 self.persist_completion(completion, sink)?;
907 self.collect_completed_subagents(sink)?;
908 return Ok(());
909 }
910 Err(mpsc::RecvTimeoutError::Timeout) => {}
911 Err(mpsc::RecvTimeoutError::Disconnected) => {
912 return Err("subagent completion channel closed".to_owned())
913 }
914 }
915 }
916 Ok(())
917 }
918
919 fn subagent_status(&self, arguments: &str) -> Value {
920 let task_id = match parse_task_id(arguments) {
921 Ok(task_id) => task_id,
922 Err(error) => return serde_json::json!({"error": error}),
923 };
924 match self
925 .subagents
926 .lock()
927 .ok()
928 .and_then(|states| states.get(&task_id).cloned())
929 {
930 Some(SubagentState::Running { .. }) => {
931 serde_json::json!({"task_id": task_id, "status": "running"})
932 }
933 Some(SubagentState::Completed(result)) => {
934 serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result})
935 }
936 None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
937 }
938 }
939
940 fn wait_subagent(&self, arguments: &str, cancellation: Option<&CancellationToken>) -> Value {
941 let value = match serde_json::from_str::<Value>(arguments) {
942 Ok(value) => value,
943 Err(_) => {
944 return serde_json::json!({"error": "wait_subagent arguments must be an object"})
945 }
946 };
947 let task_id = match value.get("task_id").and_then(Value::as_str) {
948 Some(task_id) if !task_id.trim().is_empty() => task_id.trim().to_owned(),
949 _ => return serde_json::json!({"error": "wait_subagent requires a task_id string"}),
950 };
951 let timeout_ms = value
952 .get("timeout_ms")
953 .and_then(Value::as_u64)
954 .unwrap_or(30_000)
955 .clamp(1, 600_000);
956 let state = self
957 .subagents
958 .lock()
959 .ok()
960 .and_then(|states| states.get(&task_id).cloned());
961 let Some(state) = state else {
962 return serde_json::json!({"task_id": task_id, "status": "unknown"});
963 };
964 let SubagentState::Running { control, .. } = state else {
965 if let SubagentState::Completed(result) = state {
966 return serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result});
967 }
968 unreachable!();
969 };
970 let (result_slot, wake) = &*control.done;
971 let mut result = match result_slot.lock() {
972 Ok(result) => result,
973 Err(_) => {
974 return serde_json::json!({"task_id": task_id, "status": "failed", "error": "subagent wait unavailable"})
975 }
976 };
977 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
978 while result.is_none() {
979 if cancellation.is_some_and(CancellationToken::is_cancelled) {
980 return serde_json::json!({"task_id": task_id, "status": "parent_canceled"});
981 }
982 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
983 if remaining.is_zero() {
984 return serde_json::json!({"task_id": task_id, "status": "waiting", "timed_out": true});
985 }
986 let interval = remaining.min(std::time::Duration::from_millis(25));
987 let (guard, _) = wake
988 .wait_timeout(result, interval)
989 .unwrap_or_else(|poisoned| poisoned.into_inner());
990 result = guard;
991 }
992 match result.clone() {
993 Some(result) => {
994 serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "result": result})
995 }
996 None => serde_json::json!({"task_id": task_id, "status": "waiting"}),
997 }
998 }
999
1000 fn send_subagent(&self, arguments: &str) -> Value {
1001 let value = match serde_json::from_str::<Value>(arguments) {
1002 Ok(value) => value,
1003 Err(_) => {
1004 return serde_json::json!({"error": "send_subagent arguments must be an object"})
1005 }
1006 };
1007 let task_id = match value.get("task_id").and_then(Value::as_str) {
1008 Some(task_id) if !task_id.trim().is_empty() => task_id.trim().to_owned(),
1009 _ => return serde_json::json!({"error": "send_subagent requires a task_id string"}),
1010 };
1011 let message = match value.get("message").and_then(Value::as_str) {
1012 Some(message)
1013 if !message.trim().is_empty() && message.len() <= MAX_SUBAGENT_TASK_BYTES =>
1014 {
1015 message.to_owned()
1016 }
1017 _ => {
1018 return serde_json::json!({"error": "send_subagent message must be non-empty and bounded"})
1019 }
1020 };
1021 let state = self
1022 .subagents
1023 .lock()
1024 .ok()
1025 .and_then(|states| states.get(&task_id).cloned());
1026 match state {
1027 Some(SubagentState::Running { control, .. }) => {
1028 match control.commands.send(SubagentCommand::Message(message)) {
1029 Ok(()) => serde_json::json!({"task_id": task_id, "status": "queued"}),
1030 Err(_) => {
1031 serde_json::json!({"task_id": task_id, "status": "failed", "error": "subagent command channel closed"})
1032 }
1033 }
1034 }
1035 Some(SubagentState::Completed(result)) => {
1036 serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "error": "subagent is not running"})
1037 }
1038 None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
1039 }
1040 }
1041
1042 fn cancel_subagent(&self, arguments: &str) -> Value {
1043 let task_id = match parse_task_id(arguments) {
1044 Ok(task_id) => task_id,
1045 Err(error) => return serde_json::json!({"error": error}),
1046 };
1047 let state = self
1048 .subagents
1049 .lock()
1050 .ok()
1051 .and_then(|states| states.get(&task_id).cloned());
1052 match state {
1053 Some(SubagentState::Running { control, .. }) => {
1054 control.cancellation.cancel();
1055 serde_json::json!({"task_id": task_id, "status": "cancellation_requested"})
1056 }
1057 Some(SubagentState::Completed(result)) => {
1058 serde_json::json!({"task_id": task_id, "status": terminal_status(&result), "error": "subagent is not running"})
1059 }
1060 None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
1061 }
1062 }
1063
1064 pub(crate) fn apply_settings(
1065 &mut self,
1066 home: &Path,
1067 model: String,
1068 effort: Option<String>,
1069 ) -> Result<(), String> {
1070 let config = Config::load_or_create(home).map_err(|error| error.to_string())?;
1071 let mut settings = config.resolved_llm().map_err(|error| error.to_string())?;
1072 settings.model = model.trim().to_owned();
1073 settings.effort = effort
1074 .map(|value| value.trim().to_owned())
1075 .filter(|value| !value.is_empty());
1076 settings.base_url = self.session.llm.base_url.clone();
1078 settings.api_key_env = self.session.llm.api_key_env.clone();
1079 let provider = Provider::new(&settings).map_err(|error| error.to_string())?;
1080 Config::save_selection(home, &settings.model, settings.effort.as_deref())
1082 .map_err(|error| error.to_string())?;
1083 self.session
1084 .append_provider_settings(settings.model.clone(), settings.effort.clone())
1085 .map_err(|error| error.to_string())?;
1086 self.session.llm = settings;
1087 self.provider = provider;
1088 self.context_window = self.provider.context_window();
1089 Ok(())
1090 }
1091
1092 fn should_compact(&self, messages: &[ChatMessage]) -> bool {
1093 self.context_window
1094 .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
1095 }
1096
1097 fn compaction_boundary(&self) -> Option<usize> {
1098 let latest_boundary = self
1099 .session
1100 .history
1101 .iter()
1102 .rev()
1103 .find_map(|record| match record {
1104 crate::session::SessionHistoryRecord::Compaction(compaction) => {
1105 Some(compaction.first_kept_message)
1106 }
1107 _ => None,
1108 });
1109 find_compaction_boundary(&self.session.messages, latest_boundary)
1110 }
1111
1112 fn compact_context<S: EventSink>(
1113 &mut self,
1114 sink: &mut S,
1115 cancellation: Option<&crate::cancellation::CancellationToken>,
1116 tokens_before: usize,
1117 ) -> Result<(), String> {
1118 let Some(boundary) = self.compaction_boundary() else {
1119 return Err("context cannot be compacted without an earlier complete turn".to_owned());
1120 };
1121 let Some(cancellation) = cancellation else {
1122 return Err("context compaction requires a cancellable turn".to_owned());
1123 };
1124 sink.compaction_started()
1125 .map_err(|error| format!("unable to emit compaction state: {error}"))?;
1126 let context_messages = self.session.provider_messages();
1127 let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
1128 summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
1129 summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
1130 summary_messages.extend(context_messages.into_iter().skip(1));
1131 let summary = match self.provider.summarize(&summary_messages, cancellation) {
1132 Ok(summary) => redact_secret(&summary, Some(self.provider.api_key())),
1133 Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
1134 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1135 }
1136 Err(error) => return Err(format!("unable to compact context: {error}")),
1137 };
1138 self.session
1139 .append_compaction(summary, boundary, tokens_before)
1140 .map_err(|error| format!("unable to persist context compaction: {error}"))?;
1141 let tokens_after = estimate_context_tokens(&self.session.provider_messages());
1142 sink.compaction_finished(tokens_before, tokens_after)
1143 .map_err(|error| format!("unable to emit compaction state: {error}"))?;
1144 Ok(())
1145 }
1146
1147 pub(crate) fn handle_message<S: EventSink>(
1148 &mut self,
1149 text: &str,
1150 sink: &mut S,
1151 cancellation: Option<&crate::cancellation::CancellationToken>,
1152 ) -> Result<(), String> {
1153 let logical_turn_id = format!("turn-{}-{}", self.session.id, self.session.history.len());
1154 self.collect_completed_subagents(sink)?;
1155 if cancellation.is_some_and(CancellationToken::is_cancelled) {
1156 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1157 }
1158 self.deliver_pending_background_results(&logical_turn_id, sink)?;
1159 let secret = self.provider.api_key().to_owned();
1160 let expanded = expand_skill_invocation(text, &self.session.skills)?;
1161 let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
1162 if let Err(error) = self.session.append_message(user_message) {
1163 if cancellation.is_some_and(|token| token.is_cancelled()) {
1164 let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1165 return interruption
1166 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1167 }
1168 return Err(error.to_string());
1169 }
1170 if let Some(name) = expanded.attached_skill.as_deref() {
1171 sink.skill_instruction_attached(name)
1172 .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
1173 }
1174
1175 let mut compacted_for_turn = false;
1176 loop {
1177 self.collect_completed_subagents(sink)?;
1178 if cancellation.is_some_and(CancellationToken::is_cancelled) {
1179 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1180 }
1181 self.deliver_pending_background_results(&logical_turn_id, sink)?;
1182 let mut messages = self.session.provider_messages();
1183 let tokens_before = estimate_context_tokens(&messages);
1184 if !compacted_for_turn && self.should_compact(&messages) {
1185 self.compact_context(sink, cancellation, tokens_before)?;
1186 compacted_for_turn = true;
1187 messages = self.session.provider_messages();
1188 }
1189 sink.context_usage(estimate_context_tokens(&messages))
1190 .map_err(|error| format!("unable to emit context usage: {error}"))?;
1191 let mut raw_content = String::new();
1192 let mut redactor = SecretRedactor::new(&secret);
1193 let mut reasoning_active = false;
1194 let stream_result = {
1195 let mut on_event = |event: ProviderStreamEvent| -> io::Result<()> {
1196 match event {
1197 ProviderStreamEvent::ReasoningStarted => {
1198 if !reasoning_active {
1199 reasoning_active = true;
1200 sink.reasoning_started()?;
1201 }
1202 Ok(())
1203 }
1204 ProviderStreamEvent::Text(delta) => {
1205 if reasoning_active {
1206 reasoning_active = false;
1207 sink.reasoning_completed()?;
1208 }
1209 raw_content.push_str(&delta);
1210 redactor.push(&delta, |safe_delta| {
1211 sink.emit_event(&ProtocolEvent::AssistantDelta {
1212 text: safe_delta.to_owned(),
1213 })
1214 })
1215 }
1216 }
1217 };
1218 match cancellation {
1219 Some(token) => self
1220 .provider
1221 .stream_chat_cancellable_with_options_and_events(
1222 &messages,
1223 &mut on_event,
1224 token,
1225 true,
1226 true,
1227 ),
1228 None => self.provider.stream_chat(&messages, &mut |delta| {
1229 raw_content.push_str(delta);
1230 redactor.push(delta, |safe_delta| {
1231 sink.emit_event(&ProtocolEvent::AssistantDelta {
1232 text: safe_delta.to_owned(),
1233 })
1234 })
1235 }),
1236 }
1237 };
1238 redactor
1239 .finish(|safe_delta| {
1240 sink.emit_event(&ProtocolEvent::AssistantDelta {
1241 text: safe_delta.to_owned(),
1242 })
1243 })
1244 .map_err(|error| format!("unable to write assistant delta: {error}"))?;
1245 let turn = match stream_result {
1246 Ok(turn) => {
1247 if reasoning_active {
1248 sink.reasoning_completed()
1249 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1250 }
1251 turn
1252 }
1253 Err(error)
1254 if cancellation.is_some_and(|token| token.is_cancelled())
1255 || error.is_cancelled() =>
1256 {
1257 if reasoning_active {
1258 sink.reasoning_completed()
1259 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1260 }
1261 let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
1262 content: raw_content,
1263 tool_calls: Vec::new(),
1264 reasoning_details: Vec::new(),
1265 });
1266 return self.interrupt(
1267 sink,
1268 PROVIDER_PHASE,
1269 &partial.content,
1270 &partial.tool_calls,
1271 Vec::new(),
1272 );
1273 }
1274 Err(error) => {
1275 if reasoning_active {
1276 sink.reasoning_completed()
1277 .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
1278 }
1279 return Err(error.to_string());
1280 }
1281 };
1282 let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
1283
1284 if turn.tool_calls.iter().any(|call| {
1285 !matches!(
1286 call.name.as_str(),
1287 "cmd"
1288 | "spawn_subagent"
1289 | "check_subagent"
1290 | "wait_subagent"
1291 | "send_subagent"
1292 | "cancel_subagent"
1293 )
1294 }) {
1295 if canceled_after_stream {
1296 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
1297 }
1298 return Err("provider requested an unsupported tool".to_owned());
1299 }
1300 let safe_tool_calls = turn
1301 .tool_calls
1302 .iter()
1303 .map(|call| safe_tool_call(call, &secret))
1304 .collect::<Vec<_>>();
1305 let assistant_content = redact_secret(&turn.content, Some(&secret));
1306 let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
1307 let mut assistant =
1308 ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
1309 assistant.reasoning_details = safe_reasoning_details;
1310 if let Err(error) = self.session.append_message(assistant) {
1311 if cancellation.is_some_and(|token| token.is_cancelled()) {
1312 let interruption = self.interrupt(
1313 sink,
1314 PROVIDER_PHASE,
1315 &assistant_content,
1316 &turn.tool_calls,
1317 Vec::new(),
1318 );
1319 return interruption
1320 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1321 }
1322 return Err(error.to_string());
1323 }
1324
1325 if safe_tool_calls.is_empty() {
1326 self.collect_completed_subagents(sink)?;
1327 if canceled_after_stream
1328 || cancellation.is_some_and(CancellationToken::is_cancelled)
1329 {
1330 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1331 }
1332 if self.deliver_pending_background_results(&logical_turn_id, sink)? > 0 {
1333 continue;
1334 }
1335 if self.has_running_subagents_for_turn(&logical_turn_id) {
1336 self.wait_for_attached_completion(&logical_turn_id, sink, cancellation)?;
1337 if cancellation.is_some_and(CancellationToken::is_cancelled) {
1338 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1339 }
1340 self.deliver_pending_background_results(&logical_turn_id, sink)?;
1341 continue;
1342 }
1343 if cancellation.is_some_and(|token| !token.try_complete()) {
1344 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
1345 }
1346 sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
1347 .map_err(|error| format!("unable to emit context usage: {error}"))?;
1348 sink.emit_event(&ProtocolEvent::TurnEnd)
1349 .map_err(|error| format!("unable to write turn end: {error}"))?;
1350 return Ok(());
1351 }
1352
1353 for safe_call in &safe_tool_calls {
1354 sink.emit_event(&ProtocolEvent::ToolCall {
1355 id: safe_call.id.clone(),
1356 name: safe_call.name.clone(),
1357 arguments: safe_call.arguments.clone(),
1358 })
1359 .map_err(|error| format!("unable to write tool call: {error}"))?;
1360 }
1361 for (index, raw_call) in turn.tool_calls.iter().enumerate() {
1362 let safe_call = &safe_tool_calls[index];
1363 let result = if raw_call.name == "spawn_subagent" {
1364 match parse_subagent_arguments(&raw_call.arguments) {
1365 Ok(task) => self.spawn_subagent(task, &logical_turn_id),
1366 Err(error) => serde_json::json!({"error": error}),
1367 }
1368 } else if raw_call.name == "check_subagent" {
1369 self.subagent_status(&raw_call.arguments)
1370 } else if raw_call.name == "wait_subagent" {
1371 self.wait_subagent(&raw_call.arguments, cancellation)
1372 } else if raw_call.name == "send_subagent" {
1373 self.send_subagent(&raw_call.arguments)
1374 } else if raw_call.name == "cancel_subagent" {
1375 self.cancel_subagent(&raw_call.arguments)
1376 } else if cancellation.is_some_and(|token| token.is_cancelled()) {
1377 serde_json::to_value(crate::command::canceled_result(
1378 &safe_call.arguments,
1379 &secret,
1380 ))
1381 .map_err(|error| format!("unable to encode cmd result: {error}"))?
1382 } else {
1383 serde_json::to_value(crate::command::execute_with_cancellation(
1384 &raw_call.arguments,
1385 &self.session.cwd,
1386 self.provider.api_key_env(),
1387 Some(&secret),
1388 cancellation,
1389 ))
1390 .map_err(|error| format!("unable to encode cmd result: {error}"))?
1391 };
1392 let mut result = redact_json_value(result, &secret);
1393 if raw_call.name == "wait_subagent"
1394 && cancellation.is_some_and(CancellationToken::is_cancelled)
1395 {
1396 result = serde_json::json!({
1397 "task_id": parse_task_id(&raw_call.arguments).ok(),
1398 "status": "parent_canceled"
1399 });
1400 }
1401 let tool_content = serde_json::to_string(&result)
1402 .map_err(|error| format!("unable to encode tool result: {error}"))?;
1403 let tool_message = ChatMessage::tool(
1404 safe_call.id.clone(),
1405 safe_call.name.clone(),
1406 redact_secret(&tool_content, Some(&secret)),
1407 );
1408 let observation = crate::session::SessionToolResult {
1409 id: safe_call.id.clone(),
1410 name: safe_call.name.clone(),
1411 result: result.clone(),
1412 };
1413 if let Err(error) = self.session.append_message(tool_message) {
1414 if cancellation.is_some_and(|token| token.is_cancelled()) {
1415 let interruption =
1416 self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
1417 return interruption
1418 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
1419 }
1420 return Err(error.to_string());
1421 }
1422 sink.emit_event(&ProtocolEvent::ToolResult {
1423 id: safe_call.id.clone(),
1424 name: safe_call.name.clone(),
1425 result: result.clone(),
1426 })
1427 .map_err(|error| format!("unable to write tool result: {error}"))?;
1428 if cancellation.is_some_and(|token| token.is_cancelled()) {
1429 for pending_call in safe_tool_calls.iter().skip(index + 1) {
1430 let pending_result = redact_json_value(
1431 serde_json::to_value(crate::command::canceled_result(
1432 &pending_call.arguments,
1433 &secret,
1434 ))
1435 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
1436 &secret,
1437 );
1438 let pending_content = serde_json::to_string(&pending_result)
1439 .map_err(|error| format!("unable to encode tool result: {error}"))?;
1440 let pending_message = ChatMessage::tool(
1441 pending_call.id.clone(),
1442 pending_call.name.clone(),
1443 redact_secret(&pending_content, Some(&secret)),
1444 );
1445 let pending_observation = crate::session::SessionToolResult {
1446 id: pending_call.id.clone(),
1447 name: pending_call.name.clone(),
1448 result: pending_result.clone(),
1449 };
1450 if let Err(error) = self.session.append_message(pending_message) {
1451 if cancellation.is_some_and(|token| token.is_cancelled()) {
1452 let interruption = self.interrupt(
1453 sink,
1454 COMMAND_PHASE,
1455 "",
1456 &[],
1457 vec![pending_observation],
1458 );
1459 return interruption.map_err(|interrupt_error| {
1460 format!("{error}; {interrupt_error}")
1461 });
1462 }
1463 return Err(error.to_string());
1464 }
1465 sink.emit_event(&ProtocolEvent::ToolResult {
1466 id: pending_call.id.clone(),
1467 name: pending_call.name.clone(),
1468 result: pending_result.clone(),
1469 })
1470 .map_err(|error| format!("unable to write tool result: {error}"))?;
1471 }
1472 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
1473 }
1474 if raw_call.name == "wait_subagent" && result.get("result").is_some() {
1475 if let Ok(task_id) = parse_task_id(&raw_call.arguments) {
1476 self.mark_wait_delivery(&task_id, &logical_turn_id, sink)?;
1477 }
1478 }
1479 }
1480 self.collect_completed_subagents(sink)?;
1481 if cancellation.is_some_and(CancellationToken::is_cancelled) {
1482 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
1483 }
1484 self.deliver_pending_background_results(&logical_turn_id, sink)?;
1485 }
1486 }
1487
1488 fn interrupt<S: EventSink>(
1489 &mut self,
1490 sink: &mut S,
1491 phase: &str,
1492 assistant_text: &str,
1493 tool_calls: &[ChatToolCall],
1494 tool_results: Vec<crate::session::SessionToolResult>,
1495 ) -> Result<(), String> {
1496 let secret = self.provider.api_key();
1497 let safe_tool_calls = tool_calls
1498 .iter()
1499 .filter(|call| call.name == "cmd")
1500 .map(|call| safe_partial_tool_call(call, secret))
1501 .collect::<Vec<_>>();
1502 let safe_tool_results = tool_results.clone();
1503 let interruption = crate::session::InterruptionRecord {
1504 timestamp: 0,
1505 reason: USER_CANCEL_REASON.to_owned(),
1506 phase: phase.to_owned(),
1507 assistant_text: redact_secret(assistant_text, Some(secret)),
1508 tool_calls: safe_tool_calls.clone(),
1509 tool_results,
1510 };
1511 let persistence_error = self.session.append_interruption(interruption).err();
1512 let mut event_error = None;
1513 for call in &safe_tool_calls {
1514 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
1515 id: call.id.clone(),
1516 name: call.name.clone(),
1517 arguments: call.arguments.clone(),
1518 }) {
1519 event_error.get_or_insert(error);
1520 }
1521 }
1522 for observation in &safe_tool_results {
1523 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
1524 id: observation.id.clone(),
1525 name: observation.name.clone(),
1526 result: observation.result.clone(),
1527 }) {
1528 event_error.get_or_insert(error);
1529 }
1530 }
1531 if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
1532 reason: USER_CANCEL_REASON.to_owned(),
1533 phase: phase.to_owned(),
1534 }) {
1535 event_error.get_or_insert(error);
1536 }
1537 match (persistence_error, event_error) {
1538 (None, None) => Ok(()),
1539 (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
1540 (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
1541 (Some(persistence), Some(event)) => Err(format!(
1542 "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
1543 )),
1544 }
1545 }
1546}
1547
1548fn parse_task_id(arguments: &str) -> Result<String, String> {
1549 serde_json::from_str::<Value>(arguments)
1550 .ok()
1551 .and_then(|value| {
1552 value
1553 .get("task_id")
1554 .and_then(Value::as_str)
1555 .filter(|task_id| !task_id.trim().is_empty())
1556 .map(|task_id| task_id.trim().to_owned())
1557 })
1558 .ok_or_else(|| "subagent requires a task_id string".to_owned())
1559}
1560
1561fn subagent_canceled_result(shutdown: &AtomicBool) -> Value {
1562 if shutdown.load(Ordering::Acquire) {
1563 serde_json::json!({"interrupted": true, "reason": "process_shutdown"})
1564 } else {
1565 serde_json::json!({"cancelled": true})
1566 }
1567}
1568
1569fn pending_from_completion(completion: SubagentCompletion) -> BackgroundResultPending {
1570 BackgroundResultPending {
1571 timestamp: 0,
1572 completion_id: completion.completion_id,
1573 task_id: completion.task_id,
1574 child_session_id: completion.child_session_id,
1575 task: completion.task,
1576 status: completion.status,
1577 result: completion.result,
1578 completed_at: completion.completed_at,
1579 }
1580}
1581
1582fn completion_id_for_child(child_session_id: &str) -> String {
1583 format!("completion-{child_session_id}")
1584}
1585
1586fn unix_timestamp() -> u64 {
1587 std::time::SystemTime::now()
1588 .duration_since(std::time::UNIX_EPOCH)
1589 .unwrap_or_default()
1590 .as_secs()
1591}
1592
1593fn child_status_name(status: ChildSessionStatus) -> &'static str {
1594 match status {
1595 ChildSessionStatus::Running => "running",
1596 ChildSessionStatus::Completed => "completed",
1597 ChildSessionStatus::Failed => "failed",
1598 ChildSessionStatus::Canceled => "canceled",
1599 ChildSessionStatus::Interrupted => "interrupted",
1600 }
1601}
1602
1603fn terminal_status(result: &Value) -> &'static str {
1604 if result.get("interrupted").is_some() {
1605 "interrupted"
1606 } else if result.get("cancelled").is_some() {
1607 "canceled"
1608 } else if result.get("error").is_some() {
1609 "failed"
1610 } else {
1611 "completed"
1612 }
1613}
1614
1615fn parse_subagent_arguments(arguments: &str) -> Result<String, String> {
1616 let value: Value = serde_json::from_str(arguments)
1617 .map_err(|_| "spawn_subagent arguments must be a JSON object")?;
1618 let object = value
1619 .as_object()
1620 .ok_or("spawn_subagent arguments must be a JSON object")?;
1621 if object.keys().any(|key| key != "task") {
1622 return Err(
1623 "spawn_subagent accepts only task; model and effort always inherit from the session"
1624 .to_owned(),
1625 );
1626 }
1627 let task = object
1628 .get("task")
1629 .and_then(Value::as_str)
1630 .ok_or("spawn_subagent task must be a string")?
1631 .trim()
1632 .to_owned();
1633 if task.is_empty() || task.len() > MAX_SUBAGENT_TASK_BYTES {
1634 return Err("spawn_subagent task must be non-empty and bounded".to_owned());
1635 }
1636 Ok(task)
1637}
1638
1639struct SubagentRunOptions {
1640 cancellation: Option<CancellationToken>,
1641 commands: mpsc::Receiver<SubagentCommand>,
1642 shutdown: Arc<AtomicBool>,
1643 activity: Option<(String, mpsc::Sender<SubagentActivity>)>,
1644}
1645
1646fn run_subagent(
1647 settings: crate::config::LlmSettings,
1648 _boot_context: String,
1649 cwd: std::path::PathBuf,
1650 task: String,
1651 options: SubagentRunOptions,
1652 child_session: &mut ChildSession,
1653) -> Value {
1654 let selected_model = settings.model.clone();
1655 let selected_effort = settings.effort.clone();
1656 let provider = match Provider::new(&settings) {
1657 Ok(provider) => provider,
1658 Err(error) => return serde_json::json!({"error": error.to_string()}),
1659 };
1660 let secret = provider.api_key().to_owned();
1661 if let Err(error) =
1662 child_session.append_message(ChatMessage::user(redact_secret(&task, Some(&secret))))
1663 {
1664 return serde_json::json!({"error": error.to_string()});
1665 }
1666 let activity = options.activity;
1667 let commands = options.commands;
1668 let shutdown = options.shutdown;
1669 let cancellation = options.cancellation.unwrap_or_default();
1670 loop {
1671 while let Ok(command) = commands.try_recv() {
1672 match command {
1673 SubagentCommand::Message(message) => {
1674 let message = ChatMessage::user(redact_secret(&message, Some(&secret)));
1675 if let Err(error) = child_session.append_message(message) {
1676 return serde_json::json!({"error": error.to_string()});
1677 }
1678 }
1679 }
1680 }
1681 if cancellation.is_cancelled() {
1682 return subagent_canceled_result(&shutdown);
1683 }
1684 let messages = child_session.provider_messages();
1685 let activity_for_stream = activity.clone();
1686 let mut reasoning_active = false;
1687 let mut on_event = move |event: ProviderStreamEvent| -> std::io::Result<()> {
1688 let Some((task_id, sender)) = activity_for_stream.as_ref() else {
1689 return Ok(());
1690 };
1691 match event {
1692 ProviderStreamEvent::ReasoningStarted => {
1693 if !reasoning_active {
1694 reasoning_active = true;
1695 let _ = sender.send(SubagentActivity::ReasoningStarted {
1696 task_id: task_id.clone(),
1697 });
1698 }
1699 }
1700 ProviderStreamEvent::Text(text) => {
1701 if reasoning_active {
1702 reasoning_active = false;
1703 let _ = sender.send(SubagentActivity::ReasoningCompleted {
1704 task_id: task_id.clone(),
1705 });
1706 }
1707 let _ = sender.send(SubagentActivity::Event {
1708 task_id: task_id.clone(),
1709 event: ProtocolEvent::AssistantDelta { text },
1710 });
1711 }
1712 }
1713 Ok(())
1714 };
1715 let turn = match provider.stream_chat_cancellable_with_options_and_events(
1716 &messages,
1717 &mut on_event,
1718 &cancellation,
1719 true,
1720 false,
1721 ) {
1722 Ok(turn) => turn,
1723 Err(error) if error.is_cancelled() || cancellation.is_cancelled() => {
1724 return subagent_canceled_result(&shutdown)
1725 }
1726 Err(error) => return serde_json::json!({"error": error.to_string()}),
1727 };
1728 if reasoning_active {
1729 if let Some((task_id, sender)) = activity.as_ref() {
1730 let _ = sender.send(SubagentActivity::ReasoningCompleted {
1731 task_id: task_id.clone(),
1732 });
1733 }
1734 }
1735 if turn.tool_calls.iter().any(|call| call.name != "cmd") {
1736 return serde_json::json!({"error": "subagent requested an unsupported tool"});
1737 }
1738 let safe_tool_calls = turn
1739 .tool_calls
1740 .iter()
1741 .map(|call| safe_tool_call(call, &secret))
1742 .collect::<Vec<_>>();
1743 let mut assistant =
1744 ChatMessage::assistant(redact_secret(&turn.content, Some(&secret)), safe_tool_calls);
1745 assistant.reasoning_details = (!turn.reasoning_details.is_empty()).then(|| {
1746 turn.reasoning_details
1747 .iter()
1748 .map(|detail| redact_json_value(detail.clone(), &secret))
1749 .collect()
1750 });
1751 if let Err(error) = child_session.append_message(assistant) {
1752 return serde_json::json!({"error": error.to_string()});
1753 }
1754 if turn.tool_calls.is_empty() {
1755 return serde_json::json!({"model": selected_model, "effort": selected_effort, "output": redact_secret(&turn.content, Some(&secret))});
1756 }
1757 for call in turn.tool_calls {
1758 if let Some((task_id, sender)) = activity.as_ref() {
1759 let _ = sender.send(SubagentActivity::Event {
1760 task_id: task_id.clone(),
1761 event: ProtocolEvent::ToolCall {
1762 id: call.id.clone(),
1763 name: call.name.clone(),
1764 arguments: redact_secret(&call.arguments, Some(&secret)),
1765 },
1766 });
1767 }
1768 let result = crate::command::execute_with_cancellation(
1769 &call.arguments,
1770 &cwd,
1771 provider.api_key_env(),
1772 Some(provider.api_key()),
1773 Some(&cancellation),
1774 );
1775 let result_value = redact_json_value(
1776 serde_json::to_value(&result).unwrap_or_else(
1777 |_| serde_json::json!({"error":"unable to encode command result"}),
1778 ),
1779 &secret,
1780 );
1781 if let Some((task_id, sender)) = activity.as_ref() {
1782 let _ = sender.send(SubagentActivity::Event {
1783 task_id: task_id.clone(),
1784 event: ProtocolEvent::ToolResult {
1785 id: call.id.clone(),
1786 name: call.name.clone(),
1787 result: result_value.clone(),
1788 },
1789 });
1790 }
1791 let content = match serde_json::to_string(&result_value) {
1792 Ok(content) => content,
1793 Err(_) => {
1794 return serde_json::json!({"error": "unable to encode subagent command result"})
1795 }
1796 };
1797 if let Err(error) =
1798 child_session.append_message(ChatMessage::tool(call.id, call.name, content))
1799 {
1800 return serde_json::json!({"error": error.to_string()});
1801 }
1802 if cancellation.is_cancelled() {
1803 return subagent_canceled_result(&shutdown);
1804 }
1805 }
1806 }
1807}
1808
1809struct SecretRedactor {
1810 secret_text: String,
1811 secret: Vec<char>,
1812 marker: String,
1813 pending: String,
1814}
1815
1816impl SecretRedactor {
1817 fn new(secret: &str) -> Self {
1818 Self {
1819 secret_text: secret.to_owned(),
1820 secret: secret.chars().collect(),
1821 marker: redaction_marker(secret).unwrap_or_default(),
1822 pending: String::new(),
1823 }
1824 }
1825
1826 fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
1827 where
1828 F: FnMut(&str) -> io::Result<()>,
1829 {
1830 if self.secret.is_empty() {
1831 return emit(text);
1832 }
1833
1834 let mut output = String::new();
1835 for character in text.chars() {
1836 self.pending.push(character);
1837 if self.pending.chars().eq(self.secret.iter().copied()) {
1838 self.pending.clear();
1839 output.push_str(&self.marker);
1840 continue;
1841 }
1842 if self.pending_is_secret_prefix() {
1843 continue;
1844 }
1845
1846 let pending = self.pending.chars().collect::<Vec<_>>();
1847 let suffix_len = (1..pending.len())
1848 .rev()
1849 .find(|length| {
1850 pending[pending.len() - length..].iter().copied().eq(self
1851 .secret
1852 .iter()
1853 .copied()
1854 .take(*length))
1855 })
1856 .unwrap_or(0);
1857 let safe_len = pending.len() - suffix_len;
1858 output.extend(pending[..safe_len].iter());
1859 self.pending = pending[safe_len..].iter().collect();
1860 }
1861
1862 if output.is_empty() {
1863 Ok(())
1864 } else {
1865 let safe_output = redact_secret(&output, Some(&self.secret_text));
1866 emit(&safe_output)
1867 }
1868 }
1869
1870 fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
1871 where
1872 F: FnMut(&str) -> io::Result<()>,
1873 {
1874 let pending = std::mem::take(&mut self.pending);
1875 if pending.is_empty() {
1876 return Ok(());
1877 }
1878 let safe_pending = redact_secret(&pending, Some(&self.secret_text));
1879 emit(&safe_pending)
1880 }
1881
1882 fn pending_is_secret_prefix(&self) -> bool {
1883 let length = self.pending.chars().count();
1884 length < self.secret.len()
1885 && self
1886 .pending
1887 .chars()
1888 .zip(self.secret.iter().copied())
1889 .all(|(pending, secret)| pending == secret)
1890 }
1891}
1892
1893fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
1896 instruction_files
1897 .into_iter()
1898 .filter(|source| {
1899 source
1900 .path
1901 .file_name()
1902 .is_some_and(|name| name == "AGENTS.md")
1903 })
1904 .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
1905 .collect()
1906}
1907
1908fn escape_xml_attribute(text: &str) -> String {
1911 text.replace('&', "&")
1912 .replace('<', "<")
1913 .replace('>', ">")
1914 .replace('\"', """)
1915 .replace('\'', "'")
1916}
1917
1918fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
1919 skills
1920 .into_iter()
1921 .map(|skill| SkillEntry {
1922 name: redact_secret(&skill.name, Some(secret)),
1923 description: redact_secret(&skill.description, Some(secret)),
1924 path: std::path::PathBuf::from(redact_secret(
1925 &skill.path.display().to_string(),
1926 Some(secret),
1927 )),
1928 contents: redact_secret(&skill.contents, Some(secret)),
1929 model_invocable: skill.model_invocable,
1930 })
1931 .collect()
1932}
1933
1934#[derive(Debug)]
1937struct ExpandedSkillInvocation {
1938 text: String,
1939 attached_skill: Option<String>,
1940}
1941
1942fn expand_skill_invocation(
1946 text: &str,
1947 skills: &[SkillEntry],
1948) -> Result<ExpandedSkillInvocation, String> {
1949 let Some(invocation) = text.strip_prefix('/') else {
1950 return Ok(ExpandedSkillInvocation {
1951 text: text.to_owned(),
1952 attached_skill: None,
1953 });
1954 };
1955 let mut pieces = invocation.splitn(2, char::is_whitespace);
1956 let name = pieces.next().unwrap_or_default();
1957 if name.is_empty() {
1958 return Err("skill command requires a skill name: /<name> [args]".to_owned());
1959 }
1960 let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
1961 return Err(format!("unknown skill: {name}"));
1962 };
1963 let arguments = pieces.next().unwrap_or_default().trim();
1964 let mut message = format!(
1965 "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
1966 escape_xml_attribute(&skill.name),
1967 escape_xml_attribute(&skill.path.display().to_string()),
1968 skill.contents.trim()
1969 );
1970 if !arguments.is_empty() {
1971 message.push_str("\n\nUser: ");
1972 message.push_str(arguments);
1973 }
1974 Ok(ExpandedSkillInvocation {
1975 text: message,
1976 attached_skill: Some(skill.name.clone()),
1977 })
1978}
1979
1980#[cfg(test)]
1981fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
1982 safe_tool_call(
1983 &ChatToolCall {
1984 id: String::new(),
1985 name: "cmd".to_owned(),
1986 arguments: arguments.to_owned(),
1987 },
1988 secret,
1989 )
1990 .arguments
1991}
1992
1993fn safe_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1994 let valid = match call.name.as_str() {
1995 "cmd" => serde_json::from_str::<Value>(&call.arguments)
1996 .ok()
1997 .and_then(|value| value.as_object().cloned())
1998 .is_some_and(|object| {
1999 object.len() == 1 && object.get("command").is_some_and(Value::is_string)
2000 }),
2001 "spawn_subagent" => parse_subagent_arguments(&call.arguments).is_ok(),
2002 "check_subagent" | "cancel_subagent" => serde_json::from_str::<Value>(&call.arguments)
2003 .ok()
2004 .and_then(|value| value.as_object().cloned())
2005 .is_some_and(|object| {
2006 object.len() == 1 && object.get("task_id").is_some_and(Value::is_string)
2007 }),
2008 "wait_subagent" => serde_json::from_str::<Value>(&call.arguments)
2009 .ok()
2010 .and_then(|value| value.as_object().cloned())
2011 .is_some_and(|object| {
2012 object.get("task_id").is_some_and(Value::is_string)
2013 && object
2014 .get("timeout_ms")
2015 .is_none_or(|timeout| timeout.as_u64().is_some())
2016 && object
2017 .keys()
2018 .all(|key| key == "task_id" || key == "timeout_ms")
2019 }),
2020 "send_subagent" => serde_json::from_str::<Value>(&call.arguments)
2021 .ok()
2022 .and_then(|value| value.as_object().cloned())
2023 .is_some_and(|object| {
2024 object.len() == 2
2025 && object.get("task_id").is_some_and(Value::is_string)
2026 && object.get("message").is_some_and(Value::is_string)
2027 }),
2028 _ => false,
2029 };
2030 let arguments = if valid {
2031 serde_json::to_string(&redact_json_value(
2032 serde_json::from_str(&call.arguments).unwrap_or(Value::Null),
2033 secret,
2034 ))
2035 .unwrap_or_else(|_| "{}".to_owned())
2036 } else {
2037 "{}".to_owned()
2038 };
2039 ChatToolCall {
2040 id: redact_secret(&call.id, Some(secret)),
2041 name: redact_secret(&call.name, Some(secret)),
2042 arguments,
2043 }
2044}
2045
2046fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
2047 let arguments = if serde_json::from_str::<Value>(&call.arguments)
2048 .ok()
2049 .and_then(|value| value.as_object().cloned())
2050 .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
2051 {
2052 safe_tool_call(call, secret).arguments
2053 } else {
2054 "{}".to_owned()
2058 };
2059 ChatToolCall {
2060 id: redact_secret(&call.id, Some(secret)),
2061 name: redact_secret(&call.name, Some(secret)),
2062 arguments,
2063 }
2064}
2065
2066fn redact_json_value(value: Value, secret: &str) -> Value {
2067 match value {
2068 Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
2069 Value::Array(values) => Value::Array(
2070 values
2071 .into_iter()
2072 .map(|value| redact_json_value(value, secret))
2073 .collect(),
2074 ),
2075 Value::Object(object) => {
2076 let marker = redaction_marker(secret).unwrap_or_default();
2077 let mut redacted = Map::new();
2078 for (key, value) in object {
2079 let mut safe_key = if is_structural_key(&key) {
2080 key
2081 } else {
2082 redact_secret(&key, Some(secret))
2083 };
2084 if redacted.contains_key(&safe_key) {
2085 if marker.is_empty() {
2086 continue;
2087 }
2088 while redacted.contains_key(&safe_key) {
2089 safe_key.push_str(&marker);
2090 }
2091 }
2092 redacted.insert(safe_key, redact_json_value(value, secret));
2093 }
2094 Value::Object(redacted)
2095 }
2096 value => value,
2097 }
2098}
2099
2100fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
2101 if details.is_empty() {
2102 return None;
2103 }
2104 match redact_json_value(Value::Array(details.to_vec()), secret) {
2105 Value::Array(details) => Some(details),
2106 _ => None,
2107 }
2108}
2109
2110fn write_version<W: Write>(mut output: W) -> io::Result<()> {
2111 writeln!(output, "lucy {}", env!("CARGO_PKG_VERSION"))
2112}
2113
2114fn parse_args(args: &[String]) -> Result<CliOptions, String> {
2115 let mut options = CliOptions {
2116 session: None,
2117 list_sessions: false,
2118 jsonl: false,
2119 tui: false,
2120 version: false,
2121 };
2122 let mut index = 0;
2123 while index < args.len() {
2124 match args[index].as_str() {
2125 "--session" => {
2126 if options.list_sessions || options.session.is_some() {
2127 return Err("--session cannot be combined or repeated".to_owned());
2128 }
2129 index += 1;
2130 let Some(id) = args.get(index) else {
2131 return Err("--session requires an id".to_owned());
2132 };
2133 options.session = Some(id.clone());
2134 }
2135 "--list-sessions" => {
2136 if options.session.is_some() || options.list_sessions {
2137 return Err("--list-sessions cannot be combined or repeated".to_owned());
2138 }
2139 options.list_sessions = true;
2140 }
2141 "--jsonl" => {
2142 if options.jsonl || options.tui {
2143 return Err("--jsonl cannot be combined or repeated".to_owned());
2144 }
2145 options.jsonl = true;
2146 }
2147 "--tui" => {
2148 if options.tui || options.jsonl {
2149 return Err("--tui cannot be combined or repeated".to_owned());
2150 }
2151 options.tui = true;
2152 }
2153 "--version" => {
2154 if options.version {
2155 return Err("--version cannot be repeated".to_owned());
2156 }
2157 options.version = true;
2158 }
2159 "--help" | "-h" => {
2160 return Err(
2161 "usage: lucy [--version] [--jsonl|--tui] [--session <id>] [--list-sessions]"
2162 .to_owned(),
2163 );
2164 }
2165 _ => return Err("unknown argument".to_owned()),
2166 }
2167 index += 1;
2168 }
2169 Ok(options)
2170}
2171
2172fn parse_input_message(line: &str) -> Result<String, String> {
2173 let record: InputRecord = serde_json::from_str(line)
2174 .map_err(|_| "input must be a JSONL message record".to_owned())?;
2175 if record.record_type != "message" {
2176 return Err("input record type must be message".to_owned());
2177 }
2178 record
2179 .text
2180 .ok_or_else(|| "message record requires a text string".to_owned())
2181}
2182
2183fn home_directory() -> Result<PathBuf, String> {
2184 std::env::var_os("HOME")
2185 .map(PathBuf::from)
2186 .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
2187}
2188
2189fn configured_api_key_env(config: &Config) -> Option<String> {
2190 let api_key_env = config
2191 .llm
2192 .api_key_env
2193 .as_deref()
2194 .unwrap_or(DEFAULT_API_KEY_ENV)
2195 .trim();
2196 (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
2197}
2198
2199fn configured_api_key(config: &Config) -> Option<String> {
2200 configured_api_key_env(config)
2201 .and_then(|api_key_env| std::env::var(api_key_env).ok())
2202 .filter(|secret| !secret.is_empty())
2203}
2204
2205fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
2206 write_diagnostic_safe_with_environment(
2207 diagnostics,
2208 message,
2209 secret,
2210 std::env::vars().map(|(_, value)| value),
2211 );
2212}
2213
2214fn write_diagnostic_safe_with_environment<W, I>(
2215 diagnostics: &mut W,
2216 message: &str,
2217 secret: Option<&str>,
2218 environment_values: I,
2219) where
2220 W: Write,
2221 I: IntoIterator<Item = String>,
2222{
2223 let mut safe_line = format!("!: {message}");
2224 safe_line = redact_secret(&safe_line, secret);
2225 let mut environment_secrets = environment_values
2226 .into_iter()
2227 .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
2228 .collect::<Vec<_>>();
2229 environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
2230 for environment_secret in environment_secrets {
2231 safe_line = redact_secret(&safe_line, Some(&environment_secret));
2232 }
2233 let _ = writeln!(diagnostics, "{safe_line}");
2234}
2235
2236fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
2237 write_diagnostic_safe(diagnostics, message, None);
2238}
2239
2240#[cfg(test)]
2241mod tests {
2242 use super::*;
2243 use crate::cancellation::CancellationToken;
2244 use std::io::{Cursor, Read, Write};
2245 use std::net::TcpListener;
2246 use std::thread;
2247
2248 #[test]
2249 fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
2250 assert!(!should_compact_context(94, 100));
2251 assert!(should_compact_context(95, 100));
2252 assert!(should_compact_context(96, 100));
2253 assert!(!should_compact_context(100, 0));
2254 }
2255
2256 #[test]
2257 fn compaction_boundary_keeps_complete_recent_turns() {
2258 let messages = [
2259 ChatMessage::user("old request".to_owned()),
2260 ChatMessage::assistant("old answer".to_owned(), Vec::new()),
2261 ChatMessage::user("recent request".to_owned()),
2262 ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
2263 ];
2264
2265 assert_eq!(find_compaction_boundary(&messages, None), Some(2));
2266 assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
2267 }
2268
2269 #[test]
2270 fn spawn_subagent_accepts_only_a_task_and_rejects_setting_overrides() {
2271 assert_eq!(
2272 parse_subagent_arguments(r#"{"task":"inspect"}"#),
2273 Ok("inspect".to_owned())
2274 );
2275 for arguments in [
2276 r#"{"task":"inspect","model":"other-model"}"#,
2277 r#"{"task":"inspect","effort":"high"}"#,
2278 ] {
2279 let error = parse_subagent_arguments(arguments).expect_err("override rejected");
2280 assert!(error.contains("model and effort always inherit from the session"));
2281 }
2282 }
2283
2284 #[test]
2285 fn spawned_worker_has_no_tool_round_limit() {
2286 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("worker listener");
2287 listener
2288 .set_nonblocking(true)
2289 .expect("worker listener nonblocking");
2290 let address = listener.local_addr().expect("worker address");
2291 let mut responses = (0..33)
2292 .map(|index| {
2293 let tool = serde_json::json!({
2294 "id": "provider-id",
2295 "object": "chat.completion.chunk",
2296 "choices": [{
2297 "index": 0,
2298 "delta": {
2299 "tool_calls": [{
2300 "index": 0,
2301 "id": format!("worker-call-{index}"),
2302 "type": "function",
2303 "function": {
2304 "name": "cmd",
2305 "arguments": "{\"command\":\"true\"}"
2306 }
2307 }]
2308 },
2309 "finish_reason": "tool_calls"
2310 }]
2311 });
2312 format!("data: {tool}\n\ndata: [DONE]\n\n")
2313 })
2314 .collect::<Vec<_>>();
2315 responses.push(normalized_provider_response("worker complete"));
2316 let expected_requests = responses.len();
2317 let server = thread::spawn(move || {
2318 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2319 let mut requests = 0;
2320 for response in responses {
2321 let (mut stream, _) = loop {
2322 match listener.accept() {
2323 Ok((stream, address)) => {
2324 stream
2325 .set_nonblocking(false)
2326 .expect("worker connection blocking");
2327 break (stream, address);
2328 }
2329 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2330 assert!(
2331 std::time::Instant::now() < deadline,
2332 "worker request timed out"
2333 );
2334 thread::sleep(std::time::Duration::from_millis(5));
2335 }
2336 Err(error) => panic!("worker accept: {error}"),
2337 }
2338 };
2339 let mut reader = std::io::BufReader::new(stream.try_clone().expect("worker clone"));
2340 let mut content_length = 0usize;
2341 loop {
2342 let mut line = String::new();
2343 reader.read_line(&mut line).expect("worker request header");
2344 if line == "\r\n" {
2345 break;
2346 }
2347 if let Some((name, value)) = line.split_once(':') {
2348 if name.eq_ignore_ascii_case("content-length") {
2349 content_length = value.trim().parse().expect("worker content length");
2350 }
2351 }
2352 }
2353 let mut body = vec![0_u8; content_length];
2354 reader.read_exact(&mut body).expect("worker request body");
2355 let header = format!(
2356 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2357 response.len()
2358 );
2359 stream.write_all(header.as_bytes()).expect("worker header");
2360 stream
2361 .write_all(response.as_bytes())
2362 .expect("worker response");
2363 stream.flush().expect("worker flush");
2364 requests += 1;
2365 }
2366 requests
2367 });
2368
2369 let key_env = format!("LUCY_WORKER_LOOP_KEY_{}", std::process::id());
2370 std::env::set_var(&key_env, "provider-secret");
2371 let settings = crate::config::LlmSettings {
2372 base_url: format!("http://{address}/v1"),
2373 model: "worker-model".to_owned(),
2374 api_key_env: key_env.clone(),
2375 effort: None,
2376 };
2377 let cwd = std::env::current_dir().expect("worker cwd");
2378 let home = std::env::temp_dir().join(format!("lucy-worker-session-{}", std::process::id()));
2379 std::fs::create_dir_all(&home).expect("worker home");
2380 let mut child_session = ChildSession::create(
2381 &home,
2382 "parent-session",
2383 &cwd,
2384 "boot context".to_owned(),
2385 settings.clone(),
2386 "inspect many steps".to_owned(),
2387 Some("provider-secret"),
2388 )
2389 .expect("child session");
2390 let (_, commands) = mpsc::channel();
2391 let result = run_subagent(
2392 settings,
2393 "boot context".to_owned(),
2394 cwd,
2395 "inspect many steps".to_owned(),
2396 SubagentRunOptions {
2397 cancellation: Some(CancellationToken::new()),
2398 commands,
2399 shutdown: Arc::new(AtomicBool::new(false)),
2400 activity: None,
2401 },
2402 &mut child_session,
2403 );
2404
2405 assert_eq!(result["output"], "worker complete");
2406 assert_eq!(server.join().expect("worker server"), expected_requests);
2407 std::env::remove_var(key_env);
2408 std::fs::remove_dir_all(home).expect("worker home cleanup");
2409 }
2410
2411 fn normalized_provider_response(text: &str) -> String {
2412 let payload = serde_json::json!({
2413 "id": "provider-id",
2414 "object": "chat.completion.chunk",
2415 "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
2416 });
2417 format!("data: {payload}\n\ndata: [DONE]\n\n")
2418 }
2419
2420 #[test]
2421 fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
2422 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
2423 let address = listener.local_addr().expect("compaction address");
2424 let responses = ["summary", "continued"];
2425 let server = thread::spawn(move || {
2426 let mut requests = Vec::new();
2427 for response_text in responses {
2428 let (mut stream, _) = listener.accept().expect("compaction request");
2429 let mut request = String::new();
2430 let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
2431 let mut content_length = 0usize;
2432 loop {
2433 let mut line = String::new();
2434 reader.read_line(&mut line).expect("request header");
2435 if line == "\r\n" {
2436 break;
2437 }
2438 if let Some((name, value)) = line.split_once(':') {
2439 if name.eq_ignore_ascii_case("content-length") {
2440 content_length = value.trim().parse().expect("content length");
2441 }
2442 }
2443 }
2444 let mut body = vec![0u8; content_length];
2445 reader.read_exact(&mut body).expect("request body");
2446 request.push_str(std::str::from_utf8(&body).expect("request JSON"));
2447 requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
2448 let payload = serde_json::json!({
2449 "choices": [{
2450 "delta": {"content": response_text},
2451 "finish_reason": null
2452 }]
2453 });
2454 let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
2455 let header = format!(
2456 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2457 body.len()
2458 );
2459 stream
2460 .write_all(header.as_bytes())
2461 .expect("response header");
2462 stream.write_all(body.as_bytes()).expect("response body");
2463 stream.flush().expect("response flush");
2464 }
2465 requests
2466 });
2467
2468 let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
2469 std::env::set_var(&key_env, "provider-secret");
2470 let settings = crate::config::LlmSettings {
2471 base_url: format!("http://{address}/v1"),
2472 model: "model".to_owned(),
2473 api_key_env: key_env.clone(),
2474 effort: None,
2475 };
2476 let provider = Provider::new(&settings).expect("provider");
2477 let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
2478 let _ = std::fs::remove_dir_all(&home);
2479 std::fs::create_dir(&home).expect("temp home");
2480 let cwd = std::env::current_dir().expect("cwd");
2481 let mut session = Session::create_with_secret(
2482 &home,
2483 &cwd,
2484 "prompt".to_owned(),
2485 settings,
2486 Some("provider-secret"),
2487 )
2488 .expect("session");
2489 session
2490 .append_message(ChatMessage::user("old request".to_owned()))
2491 .expect("old user");
2492 session
2493 .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
2494 .expect("old answer");
2495 session
2496 .append_message(ChatMessage::user("recent request".to_owned()))
2497 .expect("recent user");
2498 session
2499 .append_message(ChatMessage::assistant(
2500 "recent answer ".repeat(8_000),
2501 Vec::new(),
2502 ))
2503 .expect("recent answer");
2504
2505 struct Sink {
2506 events: Vec<ProtocolEvent>,
2507 compaction_started: bool,
2508 compaction_finished: bool,
2509 }
2510 impl EventSink for Sink {
2511 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
2512 self.events.push(event.clone());
2513 Ok(())
2514 }
2515 fn compaction_started(&mut self) -> io::Result<()> {
2516 self.compaction_started = true;
2517 Ok(())
2518 }
2519 fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
2520 self.compaction_finished = true;
2521 Ok(())
2522 }
2523 }
2524
2525 let mut harness = Harness {
2526 home: std::env::temp_dir(),
2527 session,
2528 provider,
2529 context_window: Some(1),
2530 attached_agents: Vec::new(),
2531 subagents: Arc::new(Mutex::new(HashMap::new())),
2532 completed_subagents: mpsc::channel(),
2533 subagent_activity: mpsc::channel(),
2534 };
2535 let cancellation = CancellationToken::new();
2536 let mut sink = Sink {
2537 events: Vec::new(),
2538 compaction_started: false,
2539 compaction_finished: false,
2540 };
2541 harness
2542 .handle_message("continue", &mut sink, Some(&cancellation))
2543 .expect("continued turn");
2544
2545 let requests = server.join().expect("server");
2546 assert_eq!(requests.len(), 2);
2547 assert!(requests[0].get("tools").is_none());
2548 assert!(requests[1].get("tools").is_some());
2549 assert!(sink.compaction_started);
2550 assert!(sink.compaction_finished);
2551 assert!(sink.events.iter().any(
2552 |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
2553 ));
2554 assert!(harness
2555 .session
2556 .history
2557 .iter()
2558 .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
2559 let provider_text = harness
2560 .session
2561 .provider_messages()
2562 .iter()
2563 .filter_map(|message| message.content.as_deref())
2564 .collect::<Vec<_>>()
2565 .join("\n");
2566 assert!(!provider_text.contains("old request"));
2567 assert!(provider_text.contains("continue"));
2568
2569 std::env::remove_var(key_env);
2570 std::fs::remove_dir_all(home).expect("cleanup");
2571 }
2572
2573 #[test]
2574 fn completed_subagents_are_persisted_before_the_next_parent_request() {
2575 let key_env = format!("LUCY_PARENT_NOTIFICATION_KEY_{}", std::process::id());
2576 std::env::set_var(&key_env, "provider-secret");
2577 let home =
2578 std::env::temp_dir().join(format!("lucy-parent-notification-{}", std::process::id()));
2579 let _ = std::fs::remove_dir_all(&home);
2580 std::fs::create_dir_all(&home).expect("home");
2581 let cwd = std::env::current_dir().expect("cwd");
2582 let settings = crate::config::LlmSettings {
2583 base_url: "http://localhost".to_owned(),
2584 model: "model".to_owned(),
2585 api_key_env: key_env.clone(),
2586 effort: None,
2587 };
2588 let provider = Provider::new(&settings).expect("provider");
2589 let session = Session::create_with_secret(
2590 &home,
2591 &cwd,
2592 "prompt".to_owned(),
2593 settings,
2594 Some("provider-secret"),
2595 )
2596 .expect("session");
2597 let (completion_tx, completion_rx) = mpsc::channel();
2598 let mut harness = Harness {
2599 home: home.clone(),
2600 session,
2601 provider,
2602 context_window: None,
2603 attached_agents: Vec::new(),
2604 subagents: Arc::new(Mutex::new(HashMap::new())),
2605 completed_subagents: (completion_tx, completion_rx),
2606 subagent_activity: mpsc::channel(),
2607 };
2608 harness
2609 .completed_subagents
2610 .0
2611 .send(SubagentCompletion {
2612 completion_id: "completion-1".to_owned(),
2613 task_id: "subagent-1".to_owned(),
2614 child_session_id: "child-1".to_owned(),
2615 task: "inspect".to_owned(),
2616 status: ChildSessionStatus::Completed,
2617 result: serde_json::json!({"output":"done"}),
2618 completed_at: 1,
2619 })
2620 .expect("completion");
2621 struct TestSink(Vec<ProtocolEvent>);
2622 impl EventSink for TestSink {
2623 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
2624 self.0.push(event.clone());
2625 Ok(())
2626 }
2627 }
2628 let mut sink = TestSink(Vec::new());
2629 harness
2630 .collect_completed_subagents(&mut sink)
2631 .expect("collect completion");
2632
2633 assert!(
2634 harness.session.messages.is_empty(),
2635 "a terminal child result must never become parent user input"
2636 );
2637 assert!(harness.session.history.iter().any(|record| matches!(
2638 record,
2639 crate::session::SessionHistoryRecord::BackgroundResultPending(_)
2640 )));
2641 assert!(sink.0.iter().any(|event| matches!(
2642 event,
2643 ProtocolEvent::BackgroundResultPending { task_id, .. } if task_id == "subagent-1"
2644 )));
2645 harness
2646 .mark_wait_delivery("subagent-1", "turn-1", &mut sink)
2647 .expect("wait delivery");
2648 assert_eq!(
2649 harness
2650 .deliver_pending_background_results("turn-1", &mut sink)
2651 .expect("no synthetic duplicate"),
2652 0
2653 );
2654 assert!(harness.session.provider_messages().iter().all(|message| {
2655 message.name.as_deref() != Some(crate::session::BACKGROUND_RESULT_TOOL_NAME)
2656 }));
2657 let raw = std::fs::read_to_string(&harness.session.path).expect("parent JSONL");
2658 assert!(raw.contains("background_result_pending"));
2659 assert!(!raw.contains("provider-secret"));
2660
2661 let (commands, _command_rx) = mpsc::channel();
2662 let wait_control = SubagentControl {
2663 cancellation: CancellationToken::new(),
2664 commands,
2665 done: Arc::new((Mutex::new(None), Condvar::new())),
2666 shutdown: Arc::new(AtomicBool::new(false)),
2667 };
2668 harness.subagents.lock().expect("registry").insert(
2669 "subagent-wait".to_owned(),
2670 SubagentState::Running {
2671 control: wait_control,
2672 attached_turn_id: "turn-wait".to_owned(),
2673 },
2674 );
2675 let parent_cancel = CancellationToken::new();
2676 parent_cancel.cancel();
2677 let started = std::time::Instant::now();
2678 let wait_result = harness.wait_subagent(
2679 r#"{"task_id":"subagent-wait","timeout_ms":600000}"#,
2680 Some(&parent_cancel),
2681 );
2682 assert_eq!(wait_result["status"], "parent_canceled");
2683 assert!(started.elapsed() < std::time::Duration::from_millis(100));
2684 harness
2685 .subagents
2686 .lock()
2687 .expect("registry")
2688 .remove("subagent-wait");
2689
2690 harness
2691 .completed_subagents
2692 .0
2693 .send(SubagentCompletion {
2694 completion_id: "completion-shutdown".to_owned(),
2695 task_id: "subagent-shutdown".to_owned(),
2696 child_session_id: "child-shutdown".to_owned(),
2697 task: "shutdown".to_owned(),
2698 status: ChildSessionStatus::Interrupted,
2699 result: serde_json::json!({"interrupted":true,"reason":"process_shutdown"}),
2700 completed_at: 2,
2701 })
2702 .expect("shutdown completion");
2703 let session_id = harness.session.id.clone();
2704 drop(harness);
2705 let resumed = Session::resume(&home, &session_id).expect("resume parent");
2706 assert!(resumed
2707 .undelivered_background_results()
2708 .iter()
2709 .any(|pending| {
2710 pending.completion_id == "completion-shutdown"
2711 && pending.status == ChildSessionStatus::Interrupted
2712 }));
2713 std::env::remove_var(key_env);
2714 std::fs::remove_dir_all(home).expect("cleanup");
2715 }
2716
2717 #[test]
2718 fn completion_identity_is_derived_from_the_durable_child_session() {
2719 assert_eq!(
2720 completion_id_for_child("subagent-child-a"),
2721 "completion-subagent-child-a"
2722 );
2723 assert_ne!(
2724 completion_id_for_child("subagent-child-a"),
2725 completion_id_for_child("subagent-child-b")
2726 );
2727 }
2728
2729 #[test]
2730 fn parses_only_message_records() {
2731 assert_eq!(
2732 parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
2733 "hello"
2734 );
2735 assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
2736 assert_eq!(
2737 parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
2738 ""
2739 );
2740 }
2741
2742 #[test]
2743 fn resolves_terminal_and_forced_modes() {
2744 assert_eq!(
2745 resolve_mode(&[], true, true).expect("default TUI"),
2746 FrontendMode::Tui
2747 );
2748 assert_eq!(
2749 resolve_mode(&[], true, false).expect("automatic JSONL"),
2750 FrontendMode::Jsonl
2751 );
2752 assert_eq!(
2753 resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
2754 FrontendMode::Jsonl
2755 );
2756 assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
2757 }
2758
2759 #[test]
2760 fn redactor_does_not_leak_a_secret_across_deltas() {
2761 let mut redactor = SecretRedactor::new("secret");
2762 let mut output = Vec::new();
2763 redactor
2764 .push("prefix sec", |text| {
2765 output.push(text.to_owned());
2766 Ok(())
2767 })
2768 .expect("push");
2769 redactor
2770 .push("ret suffix", |text| {
2771 output.push(text.to_owned());
2772 Ok(())
2773 })
2774 .expect("push");
2775 redactor
2776 .finish(|text| {
2777 output.push(text.to_owned());
2778 Ok(())
2779 })
2780 .expect("finish");
2781 let output = output.join("");
2782 assert_eq!(
2783 output,
2784 format!("prefix {} suffix", redaction_marker("secret").unwrap())
2785 );
2786 assert!(!output.contains("secret"));
2787 }
2788
2789 #[test]
2790 fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
2791 let mut redactor = SecretRedactor::new("n0");
2792 let mut output = String::new();
2793 redactor
2794 .push("\n0", |text| {
2795 output.push_str(text);
2796 Ok(())
2797 })
2798 .expect("push");
2799 redactor
2800 .finish(|text| {
2801 output.push_str(text);
2802 Ok(())
2803 })
2804 .expect("finish");
2805 assert!(!output.contains("n0"));
2806 assert_eq!(output, redaction_marker("n0").unwrap());
2807 }
2808
2809 #[test]
2810 fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
2811 let mut redactor = SecretRedactor::new("secret");
2812 let mut output = Vec::new();
2813 redactor
2814 .push("xsecre", |text| {
2815 output.push(text.to_owned());
2816 Ok(())
2817 })
2818 .expect("first delta");
2819 redactor
2820 .push("t", |text| {
2821 output.push(text.to_owned());
2822 Ok(())
2823 })
2824 .expect("second delta");
2825 redactor
2826 .finish(|text| {
2827 output.push(text.to_owned());
2828 Ok(())
2829 })
2830 .expect("finish");
2831 let output = output.join("");
2832 assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
2833 assert!(!output.contains("secret"));
2834 }
2835
2836 #[test]
2837 fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
2838 for secret in ["REDACTED", "[REDACTED]"] {
2839 let mut redactor = SecretRedactor::new(secret);
2840 let split = secret.len() / 2;
2841 let (first, second) = secret.split_at(split);
2842 let mut output = String::new();
2843 redactor
2844 .push(first, |text| {
2845 output.push_str(text);
2846 Ok(())
2847 })
2848 .expect("first delta");
2849 redactor
2850 .push(second, |text| {
2851 output.push_str(text);
2852 Ok(())
2853 })
2854 .expect("second delta");
2855 redactor
2856 .finish(|text| {
2857 output.push_str(text);
2858 Ok(())
2859 })
2860 .expect("finish");
2861 assert!(!output.contains(secret));
2862 assert!(output.len() <= secret.len());
2863 }
2864 }
2865
2866 #[test]
2867 fn malformed_tool_arguments_use_a_safe_copy() {
2868 let secret = "provider-secret";
2869 let escaped = secret
2870 .chars()
2871 .map(|character| format!(r#"\u{:04x}"#, character as u32))
2872 .collect::<String>();
2873 let arguments = format!(r#"{{"command":"{escaped}""#);
2874 let safe = redact_tool_arguments(&arguments, secret);
2875 assert_eq!(safe, "{}");
2876 serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
2877 assert!(!safe.contains(secret));
2878 assert!(!safe.contains(&escaped));
2879 for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
2880 assert_eq!(redact_tool_arguments(invalid, secret), "{}");
2881 }
2882 }
2883
2884 #[test]
2885 fn structured_redaction_preserves_tool_and_result_schema_keys() {
2886 let secret = "provider-secret";
2887 let value = serde_json::json!({
2888 "command": "printf provider-secret",
2889 "stdout": "provider-secret",
2890 "stderr": "ordinary",
2891 "exit_code": 0,
2892 "timed_out": false,
2893 "stdout_truncated": false,
2894 "stderr_truncated": false,
2895 "unknown-provider-secret": "provider-secret"
2896 });
2897 let redacted = redact_json_value(value, secret);
2898 for key in [
2899 "command",
2900 "stdout",
2901 "stderr",
2902 "exit_code",
2903 "timed_out",
2904 "stdout_truncated",
2905 "stderr_truncated",
2906 ] {
2907 assert!(redacted.get(key).is_some(), "missing schema key: {key}");
2908 }
2909 let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
2910 assert!(!encoded.contains(secret));
2911 assert!(redacted.get("unknown-provider-secret").is_none());
2912 }
2913
2914 #[test]
2915 fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
2916 let value = serde_json::json!({
2917 "exit_code": 0,
2918 "timed_out": false,
2919 "stdout_truncated": true,
2920 "error": null,
2921 });
2922 let redacted = redact_json_value(value, "0");
2923 assert!(redacted["exit_code"].is_number());
2924 assert!(redacted["timed_out"].is_boolean());
2925 assert!(redacted["stdout_truncated"].is_boolean());
2926 assert!(redacted["error"].is_null());
2927 }
2928
2929 #[test]
2930 fn reasoning_details_are_recursively_redacted_before_persistence() {
2931 let details = vec![serde_json::json!({
2932 "type": "reasoning.text",
2933 "text": "provider-secret",
2934 "nested": [{"value": "provider-secret"}],
2935 "provider-secret": "provider-secret"
2936 })];
2937 let redacted = redact_reasoning_details(&details, "provider-secret")
2938 .expect("non-empty reasoning details");
2939 let redacted = Value::Array(redacted);
2940 let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
2941 assert!(!encoded.contains("provider-secret"));
2942 assert_eq!(redacted[0]["type"], "reasoning.text");
2943 assert_eq!(redacted[0]["text"], "[REDACTED]");
2944 assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
2945 assert!(redacted[0].get("provider-secret").is_none());
2946 }
2947
2948 #[test]
2949 fn malformed_input_error_does_not_echo_secret_bearing_input() {
2950 let error =
2951 parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
2952 .expect_err("invalid input");
2953 assert!(!error.contains("provider-secret"));
2954 }
2955
2956 #[test]
2957 fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
2958 let mut output = Vec::new();
2959 let error = parse_input_message("not json").expect_err("invalid input");
2960 let mut protocol = ProtocolWriter::new(&mut output);
2961 protocol.error(&error).expect("error event");
2962 assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
2963 let _ = Cursor::new("");
2964 }
2965
2966 #[test]
2967 fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
2968 let secret = "lucy";
2969 let mut diagnostics = Vec::new();
2970 write_diagnostic_safe_with_environment(
2971 &mut diagnostics,
2972 secret,
2973 None,
2974 vec![secret.to_owned()],
2975 );
2976 let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
2977 assert!(!diagnostics.contains(secret));
2978 }
2979 #[test]
2980 fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
2981 let sources = vec![
2982 InstructionSource {
2983 path: std::path::PathBuf::from("/project/AGENTS.md"),
2984 contents: "agents".to_owned(),
2985 },
2986 InstructionSource {
2987 path: std::path::PathBuf::from("/project/CLAUDE.md"),
2988 contents: "claude".to_owned(),
2989 },
2990 InstructionSource {
2991 path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
2992 contents: "agents".to_owned(),
2993 },
2994 ];
2995
2996 assert_eq!(
2997 attached_agents(sources, "secret"),
2998 vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
2999 );
3000 }
3001
3002 #[test]
3003 fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
3004 let skill = SkillEntry {
3005 name: "release-notes".to_owned(),
3006 description: "Writes release notes".to_owned(),
3007 path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
3008 contents: "# Release notes\nUse the template.".to_owned(),
3009 model_invocable: true,
3010 };
3011 let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
3012 .expect("skill command");
3013 assert!(expanded.text.contains("# Release notes"));
3014 assert!(expanded.text.contains("User: v1.2"));
3015 assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
3016 let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
3017 assert_eq!(ordinary.text, "ordinary message");
3018 assert_eq!(ordinary.attached_skill, None);
3019 assert_eq!(
3020 expand_skill_invocation("/missing", &[]).unwrap_err(),
3021 "unknown skill: missing"
3022 );
3023 assert_eq!(
3024 expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
3025 "unknown skill: skill:release-notes"
3026 );
3027 }
3028}