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