Skip to main content

lucy/
app.rs

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