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::{AtomicU64, Ordering};
5use std::sync::{mpsc, Arc, Mutex};
6
7use serde::Deserialize;
8use serde_json::{Map, Value};
9
10use crate::config::{Config, DEFAULT_API_KEY_ENV};
11use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
12use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
13use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
14use crate::provider::{Provider, ProviderStreamEvent, ProviderTurn};
15use crate::redaction::{
16    conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
17    redaction_marker,
18};
19use crate::session::Session;
20
21#[derive(Debug)]
22struct CliOptions {
23    session: Option<String>,
24    list_sessions: bool,
25    jsonl: bool,
26    tui: bool,
27}
28
29#[derive(Debug, Deserialize)]
30struct InputRecord {
31    #[serde(rename = "type")]
32    record_type: String,
33    text: Option<String>,
34}
35
36const MAX_CONCURRENT_SUBAGENTS: usize = 4;
37const MAX_SUBAGENT_TASK_BYTES: usize = 64 * 1024;
38static NEXT_SUBAGENT_ID: AtomicU64 = AtomicU64::new(1);
39const USER_CANCEL_REASON: &str = "user_cancelled";
40const PROVIDER_PHASE: &str = "provider_stream";
41const COMMAND_PHASE: &str = "cmd";
42const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
43const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
44const 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.";
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum FrontendMode {
48    Jsonl,
49    Tui,
50}
51
52pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
53where
54    R: BufRead + Send + 'static,
55    W: Write,
56    E: Write,
57{
58    let home = match home_directory() {
59        Ok(home) => home,
60        Err(error) => {
61            let mut diagnostics = diagnostics;
62            write_diagnostic(&mut diagnostics, &error);
63            return 1;
64        }
65    };
66    let cwd = match std::env::current_dir() {
67        Ok(cwd) => cwd,
68        Err(_error) => {
69            let mut diagnostics = diagnostics;
70            write_diagnostic(&mut diagnostics, "unable to resolve cwd");
71            return 1;
72        }
73    };
74    run_cli_at_home_with_terminals(
75        args,
76        input,
77        output,
78        diagnostics,
79        &home,
80        &cwd,
81        io::stdin().is_terminal(),
82        io::stdout().is_terminal(),
83    )
84}
85
86pub fn run_cli_at_home<R, W, E>(
87    args: &[String],
88    input: R,
89    output: W,
90    diagnostics: E,
91    home: &Path,
92    cwd: &Path,
93) -> i32
94where
95    R: BufRead + Send + 'static,
96    W: Write,
97    E: Write,
98{
99    // The generic test/library entry point has no terminal handles. The real
100    // binary uses run_cli, which supplies the actual stdio terminal state.
101    run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
102}
103
104#[allow(clippy::too_many_arguments)]
105fn run_cli_at_home_with_terminals<R, W, E>(
106    args: &[String],
107    input: R,
108    output: W,
109    mut diagnostics: E,
110    home: &Path,
111    cwd: &Path,
112    stdin_is_tty: bool,
113    stdout_is_tty: bool,
114) -> i32
115where
116    R: BufRead + Send + 'static,
117    W: Write,
118    E: Write,
119{
120    let options = match parse_args(args) {
121        Ok(options) => options,
122        Err(error) => {
123            let mut diagnostics = diagnostics;
124            write_diagnostic(&mut diagnostics, &error);
125            return 2;
126        }
127    };
128    let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
129        Ok(mode) => mode,
130        Err(error) => {
131            write_diagnostic(&mut diagnostics, &error);
132            return 2;
133        }
134    };
135
136    if options.list_sessions {
137        let mut protocol = ProtocolWriter::new(output);
138        if let Err(error) = Config::ensure_exists(home) {
139            write_diagnostic(&mut diagnostics, &error.to_string());
140            return 1;
141        }
142        return match Session::list(home) {
143            Ok(sessions) => {
144                for session in sessions {
145                    if let Err(error) = protocol.emit_serializable(&session) {
146                        write_diagnostic(
147                            &mut diagnostics,
148                            &format!("unable to write session metadata: {error}"),
149                        );
150                        return 1;
151                    }
152                }
153                0
154            }
155            Err(error) => {
156                write_diagnostic(&mut diagnostics, &error.to_string());
157                1
158            }
159        };
160    }
161
162    let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
163    {
164        let mut session = match Session::resume(home, id) {
165            Ok(session) => session,
166            Err(error) => {
167                write_diagnostic(&mut diagnostics, &error.to_string());
168                return 1;
169            }
170        };
171        let config = match Config::load_or_create(home) {
172            Ok(config) => config,
173            Err(error) => {
174                write_diagnostic(&mut diagnostics, &error.to_string());
175                return 1;
176            }
177        };
178        let selected = match config.resolved_llm() {
179            Ok(settings) => settings,
180            Err(error) => {
181                write_diagnostic_safe(
182                    &mut diagnostics,
183                    &error.to_string(),
184                    configured_api_key(&config).as_deref(),
185                );
186                return 1;
187            }
188        };
189        session.llm.model = selected.model;
190        session.llm.effort = selected.effort;
191        let provider = match Provider::new(&session.llm) {
192            Ok(provider) => provider,
193            Err(error) => {
194                write_diagnostic(&mut diagnostics, &error.to_string());
195                return 1;
196            }
197        };
198        if let Err(error) =
199            session.append_provider_settings(session.llm.model.clone(), session.llm.effort.clone())
200        {
201            write_diagnostic_safe(
202                &mut diagnostics,
203                &error.to_string(),
204                Some(provider.api_key()),
205            );
206            return 1;
207        }
208        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
209            write_diagnostic_safe(
210                &mut diagnostics,
211                "API key conflicts with terminal UI literals",
212                Some(provider.api_key()),
213            );
214            return 1;
215        }
216        (session, provider, true, Vec::new())
217    } else {
218        let config = match Config::load_or_create(home) {
219            Ok(config) => config,
220            Err(error) => {
221                write_diagnostic(&mut diagnostics, &error.to_string());
222                return 1;
223            }
224        };
225        let configured_secret = configured_api_key(&config);
226        let api_key_env = configured_api_key_env(&config);
227        let llm = match config.resolved_llm() {
228            Ok(llm) => llm,
229            Err(error) => {
230                write_diagnostic_safe(
231                    &mut diagnostics,
232                    &error.to_string(),
233                    configured_secret.as_deref(),
234                );
235                return 1;
236            }
237        };
238        let provider = match Provider::new(&llm) {
239            Ok(provider) => provider,
240            Err(error) => {
241                write_diagnostic_safe(
242                    &mut diagnostics,
243                    &error.to_string(),
244                    configured_secret.as_deref(),
245                );
246                return 1;
247            }
248        };
249        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
250            write_diagnostic_safe(
251                &mut diagnostics,
252                "API key conflicts with terminal UI literals",
253                Some(provider.api_key()),
254            );
255            return 1;
256        }
257        let safe_cwd = match std::fs::canonicalize(cwd) {
258            Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
259            Ok(_) => {
260                write_diagnostic_safe(
261                    &mut diagnostics,
262                    "session header rejected",
263                    Some(provider.api_key()),
264                );
265                return 1;
266            }
267            Err(_) => {
268                write_diagnostic_safe(
269                    &mut diagnostics,
270                    "unable to resolve session cwd",
271                    Some(provider.api_key()),
272                );
273                return 1;
274            }
275        };
276        let context = match resolve_boot_context_with_api_key_env(
277            home,
278            &safe_cwd,
279            &config.system_prompt,
280            api_key_env.as_deref(),
281        ) {
282            Ok(context) => context,
283            Err(error) => {
284                write_diagnostic_safe(
285                    &mut diagnostics,
286                    &error.to_string(),
287                    configured_secret.as_deref(),
288                );
289                return 1;
290            }
291        };
292        let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
293        let attached_agents = attached_agents(context.instruction_files, provider.api_key());
294        let skills = redact_skills(context.skills, provider.api_key());
295        let session = match Session::create_with_skills_and_secret(
296            home,
297            &safe_cwd,
298            boot_system_prompt,
299            llm,
300            skills,
301            Some(provider.api_key()),
302        ) {
303            Ok(session) => session,
304            Err(error) => {
305                write_diagnostic_safe(
306                    &mut diagnostics,
307                    &error.to_string(),
308                    Some(provider.api_key()),
309                );
310                return 1;
311            }
312        };
313        (session, provider, false, attached_agents)
314    };
315
316    let harness = Harness {
317        home: home.to_path_buf(),
318        session,
319        provider,
320        context_window: None,
321        attached_agents,
322        subagents: Arc::new(Mutex::new(HashMap::new())),
323        completed_subagents: mpsc::channel(),
324    };
325    if mode == FrontendMode::Tui {
326        return match crate::tui::run(harness, resumed, output) {
327            Ok(()) => 0,
328            Err(error) => {
329                write_diagnostic(&mut diagnostics, &error);
330                1
331            }
332        };
333    }
334
335    let mut protocol = ProtocolWriter::new(output);
336    let mut harness = harness;
337    if let Err(error) = protocol.session(&harness.session.id, resumed) {
338        write_diagnostic_safe(
339            &mut diagnostics,
340            &format!("unable to write session event: {error}"),
341            Some(harness.provider.api_key()),
342        );
343        return 1;
344    }
345
346    let (input_tx, input_rx) = mpsc::channel();
347    std::thread::spawn(move || {
348        for line in input.lines() {
349            if input_tx.send(line).is_err() {
350                break;
351            }
352        }
353    });
354    let mut input_closed = false;
355    while !input_closed || harness.has_running_subagents() {
356        if let Some(notification) = harness.next_subagent_notification() {
357            if let Err(error) = harness.handle_message(&notification, &mut protocol, None) {
358                let error = redact_secret(&error, Some(harness.provider.api_key()));
359                let _ = protocol.error(&error);
360            }
361            continue;
362        }
363        let line = match input_rx.recv_timeout(std::time::Duration::from_millis(25)) {
364            Ok(Ok(line)) => line,
365            Ok(Err(error)) => {
366                write_diagnostic_safe(
367                    &mut diagnostics,
368                    &format!("unable to read stdin: {error}"),
369                    Some(harness.provider.api_key()),
370                );
371                return 1;
372            }
373            Err(mpsc::RecvTimeoutError::Timeout) => continue,
374            Err(mpsc::RecvTimeoutError::Disconnected) => {
375                input_closed = true;
376                continue;
377            }
378        };
379        if line.trim().is_empty() {
380            continue;
381        }
382        let text = match parse_input_message(&line) {
383            Ok(text) => text,
384            Err(error) => {
385                let error = redact_secret(&error, Some(harness.provider.api_key()));
386                if let Err(write_error) = protocol.error(&error) {
387                    write_diagnostic_safe(
388                        &mut diagnostics,
389                        &format!("unable to write protocol error: {write_error}"),
390                        Some(harness.provider.api_key()),
391                    );
392                    return 1;
393                }
394                continue;
395            }
396        };
397        if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
398            let error = redact_secret(&error, Some(harness.provider.api_key()));
399            if let Err(write_error) = protocol.error(&error) {
400                write_diagnostic_safe(
401                    &mut diagnostics,
402                    &format!("unable to write protocol error: {write_error}"),
403                    Some(harness.provider.api_key()),
404                );
405                return 1;
406            }
407        }
408    }
409    0
410}
411
412pub fn resolve_mode(
413    args: &[String],
414    stdin_is_tty: bool,
415    stdout_is_tty: bool,
416) -> Result<FrontendMode, String> {
417    let options = parse_args(args)?;
418    if options.list_sessions {
419        if options.tui {
420            return Err("--tui cannot be combined with --list-sessions".to_owned());
421        }
422        return Ok(FrontendMode::Jsonl);
423    }
424    if options.tui && !(stdin_is_tty && stdout_is_tty) {
425        return Err("--tui requires a terminal on stdin and stdout".to_owned());
426    }
427    if options.tui {
428        Ok(FrontendMode::Tui)
429    } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
430        Ok(FrontendMode::Jsonl)
431    } else {
432        Ok(FrontendMode::Tui)
433    }
434}
435
436pub(crate) struct Harness {
437    pub(crate) home: PathBuf,
438    pub(crate) session: Session,
439    pub(crate) provider: Provider,
440    /// Model context metadata resolved by the interactive frontend; `None`
441    /// keeps compaction disabled when an OpenAI-compatible provider exposes no
442    /// context-window metadata.
443    pub(crate) context_window: Option<usize>,
444    /// AGENTS.md sources selected for this newly created session's boot context.
445    /// The TUI uses these only while its first-boot welcome is visible.
446    pub(crate) attached_agents: Vec<String>,
447    subagents: Arc<Mutex<HashMap<String, SubagentState>>>,
448    completed_subagents: (
449        mpsc::Sender<SubagentCompletion>,
450        mpsc::Receiver<SubagentCompletion>,
451    ),
452}
453
454#[derive(Clone)]
455enum SubagentState {
456    Running,
457    Completed(Value),
458}
459
460struct SubagentCompletion {
461    task_id: String,
462    result: Value,
463}
464
465fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
466    context_window > 0
467        && context_tokens as u128 * 100
468            >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
469}
470
471fn find_compaction_boundary(
472    messages: &[ChatMessage],
473    previous_boundary: Option<usize>,
474) -> Option<usize> {
475    let user_starts = messages
476        .iter()
477        .enumerate()
478        .filter_map(|(index, message)| (message.role == "user").then_some(index))
479        .collect::<Vec<_>>();
480    let mut start = *user_starts.last()?;
481    let end = messages.len();
482    let mut kept_tokens = messages[start..end]
483        .iter()
484        .map(estimate_message_tokens)
485        .sum::<usize>();
486
487    while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
488        let Some(previous_start) = user_starts
489            .iter()
490            .copied()
491            .rev()
492            .find(|candidate| *candidate < start)
493        else {
494            break;
495        };
496        start = previous_start;
497        kept_tokens = messages[start..end]
498            .iter()
499            .map(estimate_message_tokens)
500            .sum::<usize>();
501    }
502
503    (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
504}
505
506impl Harness {
507    pub(crate) fn next_subagent_notification(&mut self) -> Option<String> {
508        let completion = self.completed_subagents.1.try_recv().ok()?;
509        Some(format!(
510            "Background subagent {} completed. Deliver this result to the user and continue the task: {}",
511            completion.task_id,
512            serde_json::to_string(&completion.result).unwrap_or_else(|_| "{\"error\":\"unable to encode subagent result\"}".to_owned())
513        ))
514    }
515
516    fn has_running_subagents(&self) -> bool {
517        self.subagents.lock().is_ok_and(|states| {
518            states
519                .values()
520                .any(|state| matches!(state, SubagentState::Running))
521        })
522    }
523
524    fn spawn_subagent(&self, task: String, model: Option<String>, effort: Option<String>) -> Value {
525        let mut subagents = match self.subagents.lock() {
526            Ok(subagents) => subagents,
527            Err(_) => return serde_json::json!({"error": "subagent registry unavailable"}),
528        };
529        let running = subagents
530            .values()
531            .filter(|state| matches!(state, SubagentState::Running))
532            .count();
533        if running >= MAX_CONCURRENT_SUBAGENTS {
534            return serde_json::json!({"error": format!("subagent concurrency limit is {MAX_CONCURRENT_SUBAGENTS}")});
535        }
536        let task_id = format!(
537            "subagent-{}",
538            NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed)
539        );
540        subagents.insert(task_id.clone(), SubagentState::Running);
541        drop(subagents);
542        let settings = self.session.llm.clone();
543        let boot = self.session.boot_system_prompt.clone();
544        let cwd = self.session.cwd.clone();
545        let secret = self.provider.api_key().to_owned();
546        let states = Arc::clone(&self.subagents);
547        let completed = self.completed_subagents.0.clone();
548        let completion_id = task_id.clone();
549        std::thread::spawn(move || {
550            let result = redact_json_value(
551                run_subagent(settings, boot, cwd, task, model, effort, None),
552                &secret,
553            );
554            if let Ok(mut states) = states.lock() {
555                states.insert(
556                    completion_id.clone(),
557                    SubagentState::Completed(result.clone()),
558                );
559            }
560            let _ = completed.send(SubagentCompletion {
561                task_id: completion_id,
562                result,
563            });
564        });
565        serde_json::json!({"task_id": task_id, "status": "queued"})
566    }
567
568    fn subagent_status(&self, arguments: &str) -> Value {
569        let task_id = match serde_json::from_str::<Value>(arguments)
570            .ok()
571            .and_then(|value| {
572                value
573                    .get("task_id")
574                    .and_then(Value::as_str)
575                    .map(str::to_owned)
576            }) {
577            Some(task_id) => task_id,
578            None => return serde_json::json!({"error": "check_subagent requires a task_id string"}),
579        };
580        match self
581            .subagents
582            .lock()
583            .ok()
584            .and_then(|states| states.get(&task_id).cloned())
585        {
586            Some(SubagentState::Running) => {
587                serde_json::json!({"task_id": task_id, "status": "running"})
588            }
589            Some(SubagentState::Completed(result)) => {
590                serde_json::json!({"task_id": task_id, "status": "completed", "result": result})
591            }
592            None => serde_json::json!({"task_id": task_id, "status": "unknown"}),
593        }
594    }
595
596    pub(crate) fn apply_settings(
597        &mut self,
598        home: &Path,
599        model: String,
600        effort: Option<String>,
601    ) -> Result<(), String> {
602        let config = Config::load_or_create(home).map_err(|error| error.to_string())?;
603        let mut settings = config.resolved_llm().map_err(|error| error.to_string())?;
604        settings.model = model.trim().to_owned();
605        settings.effort = effort
606            .map(|value| value.trim().to_owned())
607            .filter(|value| !value.is_empty());
608        // Endpoint and credential remain the session's established provider boundary.
609        settings.base_url = self.session.llm.base_url.clone();
610        settings.api_key_env = self.session.llm.api_key_env.clone();
611        let provider = Provider::new(&settings).map_err(|error| error.to_string())?;
612        // Validate the candidate before changing the user-owned source of truth.
613        Config::save_selection(home, &settings.model, settings.effort.as_deref())
614            .map_err(|error| error.to_string())?;
615        self.session
616            .append_provider_settings(settings.model.clone(), settings.effort.clone())
617            .map_err(|error| error.to_string())?;
618        self.session.llm = settings;
619        self.provider = provider;
620        self.context_window = self.provider.context_window();
621        Ok(())
622    }
623
624    fn should_compact(&self, messages: &[ChatMessage]) -> bool {
625        self.context_window
626            .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
627    }
628
629    fn compaction_boundary(&self) -> Option<usize> {
630        let latest_boundary = self
631            .session
632            .history
633            .iter()
634            .rev()
635            .find_map(|record| match record {
636                crate::session::SessionHistoryRecord::Compaction(compaction) => {
637                    Some(compaction.first_kept_message)
638                }
639                _ => None,
640            });
641        find_compaction_boundary(&self.session.messages, latest_boundary)
642    }
643
644    fn compact_context<S: EventSink>(
645        &mut self,
646        sink: &mut S,
647        cancellation: Option<&crate::cancellation::CancellationToken>,
648        tokens_before: usize,
649    ) -> Result<(), String> {
650        let Some(boundary) = self.compaction_boundary() else {
651            return Err("context cannot be compacted without an earlier complete turn".to_owned());
652        };
653        let Some(cancellation) = cancellation else {
654            return Err("context compaction requires a cancellable turn".to_owned());
655        };
656        sink.compaction_started()
657            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
658        let context_messages = self.session.provider_messages();
659        let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
660        summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
661        summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
662        summary_messages.extend(context_messages.into_iter().skip(1));
663        let summary = match self.provider.summarize(&summary_messages, cancellation) {
664            Ok(summary) => redact_secret(&summary, Some(self.provider.api_key())),
665            Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
666                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
667            }
668            Err(error) => return Err(format!("unable to compact context: {error}")),
669        };
670        self.session
671            .append_compaction(summary, boundary, tokens_before)
672            .map_err(|error| format!("unable to persist context compaction: {error}"))?;
673        let tokens_after = estimate_context_tokens(&self.session.provider_messages());
674        sink.compaction_finished(tokens_before, tokens_after)
675            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
676        Ok(())
677    }
678
679    pub(crate) fn handle_message<S: EventSink>(
680        &mut self,
681        text: &str,
682        sink: &mut S,
683        cancellation: Option<&crate::cancellation::CancellationToken>,
684    ) -> Result<(), String> {
685        let secret = self.provider.api_key().to_owned();
686        let expanded = expand_skill_invocation(text, &self.session.skills)?;
687        let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
688        if let Err(error) = self.session.append_message(user_message) {
689            if cancellation.is_some_and(|token| token.is_cancelled()) {
690                let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
691                return interruption
692                    .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
693            }
694            return Err(error.to_string());
695        }
696        if let Some(name) = expanded.attached_skill.as_deref() {
697            sink.skill_instruction_attached(name)
698                .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
699        }
700
701        let mut compacted_for_turn = false;
702        loop {
703            if cancellation.is_some_and(|token| token.is_cancelled()) {
704                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
705            }
706            let mut messages = self.session.provider_messages();
707            let tokens_before = estimate_context_tokens(&messages);
708            if !compacted_for_turn && self.should_compact(&messages) {
709                self.compact_context(sink, cancellation, tokens_before)?;
710                compacted_for_turn = true;
711                messages = self.session.provider_messages();
712            }
713            sink.context_usage(estimate_context_tokens(&messages))
714                .map_err(|error| format!("unable to emit context usage: {error}"))?;
715            let mut raw_content = String::new();
716            let mut redactor = SecretRedactor::new(&secret);
717            let mut reasoning_active = false;
718            let stream_result = {
719                let mut on_event = |event: ProviderStreamEvent| -> io::Result<()> {
720                    match event {
721                        ProviderStreamEvent::ReasoningStarted => {
722                            if !reasoning_active {
723                                reasoning_active = true;
724                                sink.reasoning_started()?;
725                            }
726                            Ok(())
727                        }
728                        ProviderStreamEvent::Text(delta) => {
729                            if reasoning_active {
730                                reasoning_active = false;
731                                sink.reasoning_completed()?;
732                            }
733                            raw_content.push_str(&delta);
734                            redactor.push(&delta, |safe_delta| {
735                                sink.emit_event(&ProtocolEvent::AssistantDelta {
736                                    text: safe_delta.to_owned(),
737                                })
738                            })
739                        }
740                    }
741                };
742                match cancellation {
743                    Some(token) => self
744                        .provider
745                        .stream_chat_cancellable_with_options_and_events(
746                            &messages,
747                            &mut on_event,
748                            token,
749                            true,
750                            true,
751                        ),
752                    None => self.provider.stream_chat(&messages, &mut |delta| {
753                        raw_content.push_str(delta);
754                        redactor.push(delta, |safe_delta| {
755                            sink.emit_event(&ProtocolEvent::AssistantDelta {
756                                text: safe_delta.to_owned(),
757                            })
758                        })
759                    }),
760                }
761            };
762            redactor
763                .finish(|safe_delta| {
764                    sink.emit_event(&ProtocolEvent::AssistantDelta {
765                        text: safe_delta.to_owned(),
766                    })
767                })
768                .map_err(|error| format!("unable to write assistant delta: {error}"))?;
769            let turn = match stream_result {
770                Ok(turn) => {
771                    if reasoning_active {
772                        sink.reasoning_completed()
773                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
774                    }
775                    turn
776                }
777                Err(error)
778                    if cancellation.is_some_and(|token| token.is_cancelled())
779                        || error.is_cancelled() =>
780                {
781                    if reasoning_active {
782                        sink.reasoning_completed()
783                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
784                    }
785                    let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
786                        content: raw_content,
787                        tool_calls: Vec::new(),
788                        reasoning_details: Vec::new(),
789                    });
790                    return self.interrupt(
791                        sink,
792                        PROVIDER_PHASE,
793                        &partial.content,
794                        &partial.tool_calls,
795                        Vec::new(),
796                    );
797                }
798                Err(error) => {
799                    if reasoning_active {
800                        sink.reasoning_completed()
801                            .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
802                    }
803                    return Err(error.to_string());
804                }
805            };
806            let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
807
808            if turn.tool_calls.iter().any(|call| {
809                call.name != "cmd" && call.name != "spawn_subagent" && call.name != "check_subagent"
810            }) {
811                if canceled_after_stream {
812                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
813                }
814                return Err("provider requested an unsupported tool".to_owned());
815            }
816            let safe_tool_calls = turn
817                .tool_calls
818                .iter()
819                .map(|call| safe_tool_call(call, &secret))
820                .collect::<Vec<_>>();
821            let assistant_content = redact_secret(&turn.content, Some(&secret));
822            let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
823            let mut assistant =
824                ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
825            assistant.reasoning_details = safe_reasoning_details;
826            if let Err(error) = self.session.append_message(assistant) {
827                if cancellation.is_some_and(|token| token.is_cancelled()) {
828                    let interruption = self.interrupt(
829                        sink,
830                        PROVIDER_PHASE,
831                        &assistant_content,
832                        &turn.tool_calls,
833                        Vec::new(),
834                    );
835                    return interruption
836                        .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
837                }
838                return Err(error.to_string());
839            }
840
841            if safe_tool_calls.is_empty() {
842                if canceled_after_stream || cancellation.is_some_and(|token| !token.try_complete())
843                {
844                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
845                }
846                sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
847                    .map_err(|error| format!("unable to emit context usage: {error}"))?;
848                sink.emit_event(&ProtocolEvent::TurnEnd)
849                    .map_err(|error| format!("unable to write turn end: {error}"))?;
850                return Ok(());
851            }
852
853            for safe_call in &safe_tool_calls {
854                sink.emit_event(&ProtocolEvent::ToolCall {
855                    id: safe_call.id.clone(),
856                    name: safe_call.name.clone(),
857                    arguments: safe_call.arguments.clone(),
858                })
859                .map_err(|error| format!("unable to write tool call: {error}"))?;
860            }
861            for (index, raw_call) in turn.tool_calls.iter().enumerate() {
862                let safe_call = &safe_tool_calls[index];
863                let result = if raw_call.name == "spawn_subagent" {
864                    match parse_subagent_arguments(&raw_call.arguments) {
865                        Ok((task, model, effort)) => self.spawn_subagent(task, model, effort),
866                        Err(error) => serde_json::json!({"error": error}),
867                    }
868                } else if raw_call.name == "check_subagent" {
869                    self.subagent_status(&raw_call.arguments)
870                } else if cancellation.is_some_and(|token| token.is_cancelled()) {
871                    serde_json::to_value(crate::command::canceled_result(
872                        &safe_call.arguments,
873                        &secret,
874                    ))
875                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
876                } else {
877                    serde_json::to_value(crate::command::execute_with_cancellation(
878                        &raw_call.arguments,
879                        &self.session.cwd,
880                        self.provider.api_key_env(),
881                        Some(&secret),
882                        cancellation,
883                    ))
884                    .map_err(|error| format!("unable to encode cmd result: {error}"))?
885                };
886                let result = redact_json_value(result, &secret);
887                let tool_content = serde_json::to_string(&result)
888                    .map_err(|error| format!("unable to encode tool result: {error}"))?;
889                let tool_message = ChatMessage::tool(
890                    safe_call.id.clone(),
891                    safe_call.name.clone(),
892                    redact_secret(&tool_content, Some(&secret)),
893                );
894                let observation = crate::session::SessionToolResult {
895                    id: safe_call.id.clone(),
896                    name: safe_call.name.clone(),
897                    result: result.clone(),
898                };
899                if let Err(error) = self.session.append_message(tool_message) {
900                    if cancellation.is_some_and(|token| token.is_cancelled()) {
901                        let interruption =
902                            self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
903                        return interruption
904                            .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
905                    }
906                    return Err(error.to_string());
907                }
908                sink.emit_event(&ProtocolEvent::ToolResult {
909                    id: safe_call.id.clone(),
910                    name: safe_call.name.clone(),
911                    result: result.clone(),
912                })
913                .map_err(|error| format!("unable to write tool result: {error}"))?;
914                if cancellation.is_some_and(|token| token.is_cancelled()) {
915                    for pending_call in safe_tool_calls.iter().skip(index + 1) {
916                        let pending_result = redact_json_value(
917                            serde_json::to_value(crate::command::canceled_result(
918                                &pending_call.arguments,
919                                &secret,
920                            ))
921                            .map_err(|error| format!("unable to encode cmd result: {error}"))?,
922                            &secret,
923                        );
924                        let pending_content = serde_json::to_string(&pending_result)
925                            .map_err(|error| format!("unable to encode tool result: {error}"))?;
926                        let pending_message = ChatMessage::tool(
927                            pending_call.id.clone(),
928                            pending_call.name.clone(),
929                            redact_secret(&pending_content, Some(&secret)),
930                        );
931                        let pending_observation = crate::session::SessionToolResult {
932                            id: pending_call.id.clone(),
933                            name: pending_call.name.clone(),
934                            result: pending_result.clone(),
935                        };
936                        if let Err(error) = self.session.append_message(pending_message) {
937                            if cancellation.is_some_and(|token| token.is_cancelled()) {
938                                let interruption = self.interrupt(
939                                    sink,
940                                    COMMAND_PHASE,
941                                    "",
942                                    &[],
943                                    vec![pending_observation],
944                                );
945                                return interruption.map_err(|interrupt_error| {
946                                    format!("{error}; {interrupt_error}")
947                                });
948                            }
949                            return Err(error.to_string());
950                        }
951                        sink.emit_event(&ProtocolEvent::ToolResult {
952                            id: pending_call.id.clone(),
953                            name: pending_call.name.clone(),
954                            result: pending_result.clone(),
955                        })
956                        .map_err(|error| format!("unable to write tool result: {error}"))?;
957                    }
958                    return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
959                }
960            }
961        }
962    }
963
964    fn interrupt<S: EventSink>(
965        &mut self,
966        sink: &mut S,
967        phase: &str,
968        assistant_text: &str,
969        tool_calls: &[ChatToolCall],
970        tool_results: Vec<crate::session::SessionToolResult>,
971    ) -> Result<(), String> {
972        let secret = self.provider.api_key();
973        let safe_tool_calls = tool_calls
974            .iter()
975            .filter(|call| call.name == "cmd")
976            .map(|call| safe_partial_tool_call(call, secret))
977            .collect::<Vec<_>>();
978        let safe_tool_results = tool_results.clone();
979        let interruption = crate::session::InterruptionRecord {
980            timestamp: 0,
981            reason: USER_CANCEL_REASON.to_owned(),
982            phase: phase.to_owned(),
983            assistant_text: redact_secret(assistant_text, Some(secret)),
984            tool_calls: safe_tool_calls.clone(),
985            tool_results,
986        };
987        let persistence_error = self.session.append_interruption(interruption).err();
988        let mut event_error = None;
989        for call in &safe_tool_calls {
990            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
991                id: call.id.clone(),
992                name: call.name.clone(),
993                arguments: call.arguments.clone(),
994            }) {
995                event_error.get_or_insert(error);
996            }
997        }
998        for observation in &safe_tool_results {
999            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
1000                id: observation.id.clone(),
1001                name: observation.name.clone(),
1002                result: observation.result.clone(),
1003            }) {
1004                event_error.get_or_insert(error);
1005            }
1006        }
1007        if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
1008            reason: USER_CANCEL_REASON.to_owned(),
1009            phase: phase.to_owned(),
1010        }) {
1011            event_error.get_or_insert(error);
1012        }
1013        match (persistence_error, event_error) {
1014            (None, None) => Ok(()),
1015            (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
1016            (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
1017            (Some(persistence), Some(event)) => Err(format!(
1018                "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
1019            )),
1020        }
1021    }
1022}
1023
1024fn parse_subagent_arguments(
1025    arguments: &str,
1026) -> Result<(String, Option<String>, Option<String>), String> {
1027    let value: Value = serde_json::from_str(arguments)
1028        .map_err(|_| "spawn_subagent arguments must be a JSON object")?;
1029    let object = value
1030        .as_object()
1031        .ok_or("spawn_subagent arguments must be a JSON object")?;
1032    if object
1033        .keys()
1034        .any(|key| key != "task" && key != "model" && key != "effort")
1035    {
1036        return Err("spawn_subagent arguments contain an unsupported field".to_owned());
1037    }
1038    let task = object
1039        .get("task")
1040        .and_then(Value::as_str)
1041        .ok_or("spawn_subagent task must be a string")?
1042        .trim()
1043        .to_owned();
1044    if task.is_empty() || task.len() > MAX_SUBAGENT_TASK_BYTES {
1045        return Err("spawn_subagent task must be non-empty and bounded".to_owned());
1046    }
1047    let optional = |key: &str| -> Result<Option<String>, String> {
1048        match object.get(key) {
1049            None => Ok(None),
1050            Some(Value::String(value)) if !value.trim().is_empty() => {
1051                Ok(Some(value.trim().to_owned()))
1052            }
1053            _ => Err(format!("spawn_subagent {key} must be a non-empty string")),
1054        }
1055    };
1056    Ok((task, optional("model")?, optional("effort")?))
1057}
1058
1059fn run_subagent(
1060    mut settings: crate::config::LlmSettings,
1061    boot_context: String,
1062    cwd: std::path::PathBuf,
1063    task: String,
1064    model: Option<String>,
1065    effort: Option<String>,
1066    cancellation: Option<crate::cancellation::CancellationToken>,
1067) -> Value {
1068    if let Some(model) = model {
1069        settings.model = model;
1070    }
1071    if let Some(effort) = effort {
1072        settings.effort = Some(effort);
1073    }
1074    let selected_model = settings.model.clone();
1075    let selected_effort = settings.effort.clone();
1076    let provider = match Provider::new(&settings) {
1077        Ok(provider) => provider,
1078        Err(error) => return serde_json::json!({"error": error.to_string()}),
1079    };
1080    let mut messages = vec![ChatMessage::system(boot_context), ChatMessage::user(task)];
1081    let cancellation = cancellation.unwrap_or_default();
1082    loop {
1083        if cancellation.is_cancelled() {
1084            return serde_json::json!({"cancelled": true});
1085        }
1086        let mut ignored = |_text: &str| Ok(());
1087        let turn = match provider.stream_chat_cancellable_with_options(
1088            &messages,
1089            &mut ignored,
1090            &cancellation,
1091            true,
1092            false,
1093        ) {
1094            Ok(turn) => turn,
1095            Err(error) if error.is_cancelled() || cancellation.is_cancelled() => {
1096                return serde_json::json!({"cancelled": true})
1097            }
1098            Err(error) => return serde_json::json!({"error": error.to_string()}),
1099        };
1100        if turn.tool_calls.iter().any(|call| call.name != "cmd") {
1101            return serde_json::json!({"error": "subagent requested an unsupported tool"});
1102        }
1103        messages.push(ChatMessage::assistant(
1104            turn.content.clone(),
1105            turn.tool_calls.clone(),
1106        ));
1107        if turn.tool_calls.is_empty() {
1108            return serde_json::json!({"model": selected_model, "effort": selected_effort, "output": turn.content});
1109        }
1110        for call in turn.tool_calls {
1111            let result = crate::command::execute_with_cancellation(
1112                &call.arguments,
1113                &cwd,
1114                provider.api_key_env(),
1115                Some(provider.api_key()),
1116                Some(&cancellation),
1117            );
1118            let content = match serde_json::to_string(&result) {
1119                Ok(content) => content,
1120                Err(_) => {
1121                    return serde_json::json!({"error": "unable to encode subagent command result"})
1122                }
1123            };
1124            messages.push(ChatMessage::tool(call.id, call.name, content));
1125        }
1126    }
1127}
1128
1129struct SecretRedactor {
1130    secret_text: String,
1131    secret: Vec<char>,
1132    marker: String,
1133    pending: String,
1134}
1135
1136impl SecretRedactor {
1137    fn new(secret: &str) -> Self {
1138        Self {
1139            secret_text: secret.to_owned(),
1140            secret: secret.chars().collect(),
1141            marker: redaction_marker(secret).unwrap_or_default(),
1142            pending: String::new(),
1143        }
1144    }
1145
1146    fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
1147    where
1148        F: FnMut(&str) -> io::Result<()>,
1149    {
1150        if self.secret.is_empty() {
1151            return emit(text);
1152        }
1153
1154        let mut output = String::new();
1155        for character in text.chars() {
1156            self.pending.push(character);
1157            if self.pending.chars().eq(self.secret.iter().copied()) {
1158                self.pending.clear();
1159                output.push_str(&self.marker);
1160                continue;
1161            }
1162            if self.pending_is_secret_prefix() {
1163                continue;
1164            }
1165
1166            let pending = self.pending.chars().collect::<Vec<_>>();
1167            let suffix_len = (1..pending.len())
1168                .rev()
1169                .find(|length| {
1170                    pending[pending.len() - length..].iter().copied().eq(self
1171                        .secret
1172                        .iter()
1173                        .copied()
1174                        .take(*length))
1175                })
1176                .unwrap_or(0);
1177            let safe_len = pending.len() - suffix_len;
1178            output.extend(pending[..safe_len].iter());
1179            self.pending = pending[safe_len..].iter().collect();
1180        }
1181
1182        if output.is_empty() {
1183            Ok(())
1184        } else {
1185            let safe_output = redact_secret(&output, Some(&self.secret_text));
1186            emit(&safe_output)
1187        }
1188    }
1189
1190    fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
1191    where
1192        F: FnMut(&str) -> io::Result<()>,
1193    {
1194        let pending = std::mem::take(&mut self.pending);
1195        if pending.is_empty() {
1196            return Ok(());
1197        }
1198        let safe_pending = redact_secret(&pending, Some(&self.secret_text));
1199        emit(&safe_pending)
1200    }
1201
1202    fn pending_is_secret_prefix(&self) -> bool {
1203        let length = self.pending.chars().count();
1204        length < self.secret.len()
1205            && self
1206                .pending
1207                .chars()
1208                .zip(self.secret.iter().copied())
1209                .all(|(pending, secret)| pending == secret)
1210    }
1211}
1212
1213/// Return the AGENTS.md files selected for the current new-session boot context.
1214/// Paths are secret-redacted before they can reach the terminal UI.
1215fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
1216    instruction_files
1217        .into_iter()
1218        .filter(|source| {
1219            source
1220                .path
1221                .file_name()
1222                .is_some_and(|name| name == "AGENTS.md")
1223        })
1224        .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
1225        .collect()
1226}
1227
1228/// Store a secret-safe skill snapshot with the session. The source is read
1229/// once during secure context discovery; later invocations never follow paths.
1230fn escape_xml_attribute(text: &str) -> String {
1231    text.replace('&', "&amp;")
1232        .replace('<', "&lt;")
1233        .replace('>', "&gt;")
1234        .replace('\"', "&quot;")
1235        .replace('\'', "&apos;")
1236}
1237
1238fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
1239    skills
1240        .into_iter()
1241        .map(|skill| SkillEntry {
1242            name: redact_secret(&skill.name, Some(secret)),
1243            description: redact_secret(&skill.description, Some(secret)),
1244            path: std::path::PathBuf::from(redact_secret(
1245                &skill.path.display().to_string(),
1246                Some(secret),
1247            )),
1248            contents: redact_secret(&skill.contents, Some(secret)),
1249            model_invocable: skill.model_invocable,
1250        })
1251        .collect()
1252}
1253
1254/// The message delivered to the provider and the optional name of the saved
1255/// skill snapshot that was attached to it.
1256#[derive(Debug)]
1257struct ExpandedSkillInvocation {
1258    text: String,
1259    attached_skill: Option<String>,
1260}
1261
1262/// Expand slash-prefixed skill names into the user message sent to the
1263/// provider. This deliberately adds no model-facing tool: skills are context,
1264/// not an executable capability of their own.
1265fn expand_skill_invocation(
1266    text: &str,
1267    skills: &[SkillEntry],
1268) -> Result<ExpandedSkillInvocation, String> {
1269    let Some(invocation) = text.strip_prefix('/') else {
1270        return Ok(ExpandedSkillInvocation {
1271            text: text.to_owned(),
1272            attached_skill: None,
1273        });
1274    };
1275    let mut pieces = invocation.splitn(2, char::is_whitespace);
1276    let name = pieces.next().unwrap_or_default();
1277    if name.is_empty() {
1278        return Err("skill command requires a skill name: /<name> [args]".to_owned());
1279    }
1280    let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
1281        return Err(format!("unknown skill: {name}"));
1282    };
1283    let arguments = pieces.next().unwrap_or_default().trim();
1284    let mut message = format!(
1285        "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
1286        escape_xml_attribute(&skill.name),
1287        escape_xml_attribute(&skill.path.display().to_string()),
1288        skill.contents.trim()
1289    );
1290    if !arguments.is_empty() {
1291        message.push_str("\n\nUser: ");
1292        message.push_str(arguments);
1293    }
1294    Ok(ExpandedSkillInvocation {
1295        text: message,
1296        attached_skill: Some(skill.name.clone()),
1297    })
1298}
1299
1300#[cfg(test)]
1301fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
1302    safe_tool_call(
1303        &ChatToolCall {
1304            id: String::new(),
1305            name: "cmd".to_owned(),
1306            arguments: arguments.to_owned(),
1307        },
1308        secret,
1309    )
1310    .arguments
1311}
1312
1313fn safe_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1314    let valid = match call.name.as_str() {
1315        "cmd" => serde_json::from_str::<Value>(&call.arguments)
1316            .ok()
1317            .and_then(|value| value.as_object().cloned())
1318            .is_some_and(|object| {
1319                object.len() == 1 && object.get("command").is_some_and(Value::is_string)
1320            }),
1321        "spawn_subagent" => parse_subagent_arguments(&call.arguments).is_ok(),
1322        "check_subagent" => serde_json::from_str::<Value>(&call.arguments)
1323            .ok()
1324            .and_then(|value| value.as_object().cloned())
1325            .is_some_and(|object| {
1326                object.len() == 1 && object.get("task_id").is_some_and(Value::is_string)
1327            }),
1328        _ => false,
1329    };
1330    let arguments = if valid {
1331        serde_json::to_string(&redact_json_value(
1332            serde_json::from_str(&call.arguments).unwrap_or(Value::Null),
1333            secret,
1334        ))
1335        .unwrap_or_else(|_| "{}".to_owned())
1336    } else {
1337        "{}".to_owned()
1338    };
1339    ChatToolCall {
1340        id: redact_secret(&call.id, Some(secret)),
1341        name: redact_secret(&call.name, Some(secret)),
1342        arguments,
1343    }
1344}
1345
1346fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
1347    let arguments = if serde_json::from_str::<Value>(&call.arguments)
1348        .ok()
1349        .and_then(|value| value.as_object().cloned())
1350        .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
1351    {
1352        safe_tool_call(call, secret).arguments
1353    } else {
1354        // An incomplete argument fragment is an observation only. Do not
1355        // preserve malformed provider JSON: decoding it later could expose a
1356        // credential that was hidden by the outer JSON string.
1357        "{}".to_owned()
1358    };
1359    ChatToolCall {
1360        id: redact_secret(&call.id, Some(secret)),
1361        name: redact_secret(&call.name, Some(secret)),
1362        arguments,
1363    }
1364}
1365
1366fn redact_json_value(value: Value, secret: &str) -> Value {
1367    match value {
1368        Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
1369        Value::Array(values) => Value::Array(
1370            values
1371                .into_iter()
1372                .map(|value| redact_json_value(value, secret))
1373                .collect(),
1374        ),
1375        Value::Object(object) => {
1376            let marker = redaction_marker(secret).unwrap_or_default();
1377            let mut redacted = Map::new();
1378            for (key, value) in object {
1379                let mut safe_key = if is_structural_key(&key) {
1380                    key
1381                } else {
1382                    redact_secret(&key, Some(secret))
1383                };
1384                if redacted.contains_key(&safe_key) {
1385                    if marker.is_empty() {
1386                        continue;
1387                    }
1388                    while redacted.contains_key(&safe_key) {
1389                        safe_key.push_str(&marker);
1390                    }
1391                }
1392                redacted.insert(safe_key, redact_json_value(value, secret));
1393            }
1394            Value::Object(redacted)
1395        }
1396        value => value,
1397    }
1398}
1399
1400fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
1401    if details.is_empty() {
1402        return None;
1403    }
1404    match redact_json_value(Value::Array(details.to_vec()), secret) {
1405        Value::Array(details) => Some(details),
1406        _ => None,
1407    }
1408}
1409
1410fn parse_args(args: &[String]) -> Result<CliOptions, String> {
1411    let mut options = CliOptions {
1412        session: None,
1413        list_sessions: false,
1414        jsonl: false,
1415        tui: false,
1416    };
1417    let mut index = 0;
1418    while index < args.len() {
1419        match args[index].as_str() {
1420            "--session" => {
1421                if options.list_sessions || options.session.is_some() {
1422                    return Err("--session cannot be combined or repeated".to_owned());
1423                }
1424                index += 1;
1425                let Some(id) = args.get(index) else {
1426                    return Err("--session requires an id".to_owned());
1427                };
1428                options.session = Some(id.clone());
1429            }
1430            "--list-sessions" => {
1431                if options.session.is_some() || options.list_sessions {
1432                    return Err("--list-sessions cannot be combined or repeated".to_owned());
1433                }
1434                options.list_sessions = true;
1435            }
1436            "--jsonl" => {
1437                if options.jsonl || options.tui {
1438                    return Err("--jsonl cannot be combined or repeated".to_owned());
1439                }
1440                options.jsonl = true;
1441            }
1442            "--tui" => {
1443                if options.tui || options.jsonl {
1444                    return Err("--tui cannot be combined or repeated".to_owned());
1445                }
1446                options.tui = true;
1447            }
1448            "--help" | "-h" => {
1449                return Err(
1450                    "usage: lucy [--jsonl|--tui] [--session <id>] [--list-sessions]".to_owned(),
1451                );
1452            }
1453            _ => return Err("unknown argument".to_owned()),
1454        }
1455        index += 1;
1456    }
1457    Ok(options)
1458}
1459
1460fn parse_input_message(line: &str) -> Result<String, String> {
1461    let record: InputRecord = serde_json::from_str(line)
1462        .map_err(|_| "input must be a JSONL message record".to_owned())?;
1463    if record.record_type != "message" {
1464        return Err("input record type must be message".to_owned());
1465    }
1466    record
1467        .text
1468        .ok_or_else(|| "message record requires a text string".to_owned())
1469}
1470
1471fn home_directory() -> Result<PathBuf, String> {
1472    std::env::var_os("HOME")
1473        .map(PathBuf::from)
1474        .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
1475}
1476
1477fn configured_api_key_env(config: &Config) -> Option<String> {
1478    let api_key_env = config
1479        .llm
1480        .api_key_env
1481        .as_deref()
1482        .unwrap_or(DEFAULT_API_KEY_ENV)
1483        .trim();
1484    (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
1485}
1486
1487fn configured_api_key(config: &Config) -> Option<String> {
1488    configured_api_key_env(config)
1489        .and_then(|api_key_env| std::env::var(api_key_env).ok())
1490        .filter(|secret| !secret.is_empty())
1491}
1492
1493fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
1494    write_diagnostic_safe_with_environment(
1495        diagnostics,
1496        message,
1497        secret,
1498        std::env::vars().map(|(_, value)| value),
1499    );
1500}
1501
1502fn write_diagnostic_safe_with_environment<W, I>(
1503    diagnostics: &mut W,
1504    message: &str,
1505    secret: Option<&str>,
1506    environment_values: I,
1507) where
1508    W: Write,
1509    I: IntoIterator<Item = String>,
1510{
1511    let mut safe_line = format!("!: {message}");
1512    safe_line = redact_secret(&safe_line, secret);
1513    let mut environment_secrets = environment_values
1514        .into_iter()
1515        .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
1516        .collect::<Vec<_>>();
1517    environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
1518    for environment_secret in environment_secrets {
1519        safe_line = redact_secret(&safe_line, Some(&environment_secret));
1520    }
1521    let _ = writeln!(diagnostics, "{safe_line}");
1522}
1523
1524fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
1525    write_diagnostic_safe(diagnostics, message, None);
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530    use super::*;
1531    use crate::cancellation::CancellationToken;
1532    use std::io::{Cursor, Read, Write};
1533    use std::net::TcpListener;
1534    use std::thread;
1535
1536    #[test]
1537    fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
1538        assert!(!should_compact_context(94, 100));
1539        assert!(should_compact_context(95, 100));
1540        assert!(should_compact_context(96, 100));
1541        assert!(!should_compact_context(100, 0));
1542    }
1543
1544    #[test]
1545    fn compaction_boundary_keeps_complete_recent_turns() {
1546        let messages = [
1547            ChatMessage::user("old request".to_owned()),
1548            ChatMessage::assistant("old answer".to_owned(), Vec::new()),
1549            ChatMessage::user("recent request".to_owned()),
1550            ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
1551        ];
1552
1553        assert_eq!(find_compaction_boundary(&messages, None), Some(2));
1554        assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
1555    }
1556
1557    #[test]
1558    fn spawned_worker_has_no_tool_round_limit() {
1559        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("worker listener");
1560        listener
1561            .set_nonblocking(true)
1562            .expect("worker listener nonblocking");
1563        let address = listener.local_addr().expect("worker address");
1564        let mut responses = (0..33)
1565            .map(|index| {
1566                let tool = serde_json::json!({
1567                    "id": "provider-id",
1568                    "object": "chat.completion.chunk",
1569                    "choices": [{
1570                        "index": 0,
1571                        "delta": {
1572                            "tool_calls": [{
1573                                "index": 0,
1574                                "id": format!("worker-call-{index}"),
1575                                "type": "function",
1576                                "function": {
1577                                    "name": "cmd",
1578                                    "arguments": "{\"command\":\"true\"}"
1579                                }
1580                            }]
1581                        },
1582                        "finish_reason": "tool_calls"
1583                    }]
1584                });
1585                format!("data: {tool}\n\ndata: [DONE]\n\n")
1586            })
1587            .collect::<Vec<_>>();
1588        responses.push(normalized_provider_response("worker complete"));
1589        let expected_requests = responses.len();
1590        let server = thread::spawn(move || {
1591            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1592            let mut requests = 0;
1593            for response in responses {
1594                let (mut stream, _) = loop {
1595                    match listener.accept() {
1596                        Ok((stream, address)) => {
1597                            stream
1598                                .set_nonblocking(false)
1599                                .expect("worker connection blocking");
1600                            break (stream, address);
1601                        }
1602                        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1603                            assert!(
1604                                std::time::Instant::now() < deadline,
1605                                "worker request timed out"
1606                            );
1607                            thread::sleep(std::time::Duration::from_millis(5));
1608                        }
1609                        Err(error) => panic!("worker accept: {error}"),
1610                    }
1611                };
1612                let mut reader = std::io::BufReader::new(stream.try_clone().expect("worker clone"));
1613                let mut content_length = 0usize;
1614                loop {
1615                    let mut line = String::new();
1616                    reader.read_line(&mut line).expect("worker request header");
1617                    if line == "\r\n" {
1618                        break;
1619                    }
1620                    if let Some((name, value)) = line.split_once(':') {
1621                        if name.eq_ignore_ascii_case("content-length") {
1622                            content_length = value.trim().parse().expect("worker content length");
1623                        }
1624                    }
1625                }
1626                let mut body = vec![0_u8; content_length];
1627                reader.read_exact(&mut body).expect("worker request body");
1628                let header = format!(
1629                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1630                    response.len()
1631                );
1632                stream.write_all(header.as_bytes()).expect("worker header");
1633                stream
1634                    .write_all(response.as_bytes())
1635                    .expect("worker response");
1636                stream.flush().expect("worker flush");
1637                requests += 1;
1638            }
1639            requests
1640        });
1641
1642        let key_env = format!("LUCY_WORKER_LOOP_KEY_{}", std::process::id());
1643        std::env::set_var(&key_env, "provider-secret");
1644        let settings = crate::config::LlmSettings {
1645            base_url: format!("http://{address}/v1"),
1646            model: "worker-model".to_owned(),
1647            api_key_env: key_env.clone(),
1648            effort: None,
1649        };
1650        let result = run_subagent(
1651            settings,
1652            "boot context".to_owned(),
1653            std::env::current_dir().expect("worker cwd"),
1654            "inspect many steps".to_owned(),
1655            None,
1656            None,
1657            Some(CancellationToken::new()),
1658        );
1659
1660        assert_eq!(result["output"], "worker complete");
1661        assert_eq!(server.join().expect("worker server"), expected_requests);
1662        std::env::remove_var(key_env);
1663    }
1664
1665    fn normalized_provider_response(text: &str) -> String {
1666        let payload = serde_json::json!({
1667            "id": "provider-id",
1668            "object": "chat.completion.chunk",
1669            "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
1670        });
1671        format!("data: {payload}\n\ndata: [DONE]\n\n")
1672    }
1673
1674    #[test]
1675    fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
1676        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
1677        let address = listener.local_addr().expect("compaction address");
1678        let responses = ["summary", "continued"];
1679        let server = thread::spawn(move || {
1680            let mut requests = Vec::new();
1681            for response_text in responses {
1682                let (mut stream, _) = listener.accept().expect("compaction request");
1683                let mut request = String::new();
1684                let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
1685                let mut content_length = 0usize;
1686                loop {
1687                    let mut line = String::new();
1688                    reader.read_line(&mut line).expect("request header");
1689                    if line == "\r\n" {
1690                        break;
1691                    }
1692                    if let Some((name, value)) = line.split_once(':') {
1693                        if name.eq_ignore_ascii_case("content-length") {
1694                            content_length = value.trim().parse().expect("content length");
1695                        }
1696                    }
1697                }
1698                let mut body = vec![0u8; content_length];
1699                reader.read_exact(&mut body).expect("request body");
1700                request.push_str(std::str::from_utf8(&body).expect("request JSON"));
1701                requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
1702                let payload = serde_json::json!({
1703                    "choices": [{
1704                        "delta": {"content": response_text},
1705                        "finish_reason": null
1706                    }]
1707                });
1708                let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
1709                let header = format!(
1710                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1711                    body.len()
1712                );
1713                stream
1714                    .write_all(header.as_bytes())
1715                    .expect("response header");
1716                stream.write_all(body.as_bytes()).expect("response body");
1717                stream.flush().expect("response flush");
1718            }
1719            requests
1720        });
1721
1722        let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
1723        std::env::set_var(&key_env, "provider-secret");
1724        let settings = crate::config::LlmSettings {
1725            base_url: format!("http://{address}/v1"),
1726            model: "model".to_owned(),
1727            api_key_env: key_env.clone(),
1728            effort: None,
1729        };
1730        let provider = Provider::new(&settings).expect("provider");
1731        let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
1732        let _ = std::fs::remove_dir_all(&home);
1733        std::fs::create_dir(&home).expect("temp home");
1734        let cwd = std::env::current_dir().expect("cwd");
1735        let mut session = Session::create_with_secret(
1736            &home,
1737            &cwd,
1738            "prompt".to_owned(),
1739            settings,
1740            Some("provider-secret"),
1741        )
1742        .expect("session");
1743        session
1744            .append_message(ChatMessage::user("old request".to_owned()))
1745            .expect("old user");
1746        session
1747            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1748            .expect("old answer");
1749        session
1750            .append_message(ChatMessage::user("recent request".to_owned()))
1751            .expect("recent user");
1752        session
1753            .append_message(ChatMessage::assistant(
1754                "recent answer ".repeat(8_000),
1755                Vec::new(),
1756            ))
1757            .expect("recent answer");
1758
1759        struct Sink {
1760            events: Vec<ProtocolEvent>,
1761            compaction_started: bool,
1762            compaction_finished: bool,
1763        }
1764        impl EventSink for Sink {
1765            fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
1766                self.events.push(event.clone());
1767                Ok(())
1768            }
1769            fn compaction_started(&mut self) -> io::Result<()> {
1770                self.compaction_started = true;
1771                Ok(())
1772            }
1773            fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
1774                self.compaction_finished = true;
1775                Ok(())
1776            }
1777        }
1778
1779        let mut harness = Harness {
1780            home: std::env::temp_dir(),
1781            session,
1782            provider,
1783            context_window: Some(1),
1784            attached_agents: Vec::new(),
1785            subagents: Arc::new(Mutex::new(HashMap::new())),
1786            completed_subagents: mpsc::channel(),
1787        };
1788        let cancellation = CancellationToken::new();
1789        let mut sink = Sink {
1790            events: Vec::new(),
1791            compaction_started: false,
1792            compaction_finished: false,
1793        };
1794        harness
1795            .handle_message("continue", &mut sink, Some(&cancellation))
1796            .expect("continued turn");
1797
1798        let requests = server.join().expect("server");
1799        assert_eq!(requests.len(), 2);
1800        assert!(requests[0].get("tools").is_none());
1801        assert!(requests[1].get("tools").is_some());
1802        assert!(sink.compaction_started);
1803        assert!(sink.compaction_finished);
1804        assert!(sink.events.iter().any(
1805            |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
1806        ));
1807        assert!(harness
1808            .session
1809            .history
1810            .iter()
1811            .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
1812        let provider_text = harness
1813            .session
1814            .provider_messages()
1815            .iter()
1816            .filter_map(|message| message.content.as_deref())
1817            .collect::<Vec<_>>()
1818            .join("\n");
1819        assert!(!provider_text.contains("old request"));
1820        assert!(provider_text.contains("continue"));
1821
1822        std::env::remove_var(key_env);
1823        std::fs::remove_dir_all(home).expect("cleanup");
1824    }
1825
1826    #[test]
1827    fn parses_only_message_records() {
1828        assert_eq!(
1829            parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
1830            "hello"
1831        );
1832        assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
1833        assert_eq!(
1834            parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
1835            ""
1836        );
1837    }
1838
1839    #[test]
1840    fn resolves_terminal_and_forced_modes() {
1841        assert_eq!(
1842            resolve_mode(&[], true, true).expect("default TUI"),
1843            FrontendMode::Tui
1844        );
1845        assert_eq!(
1846            resolve_mode(&[], true, false).expect("automatic JSONL"),
1847            FrontendMode::Jsonl
1848        );
1849        assert_eq!(
1850            resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
1851            FrontendMode::Jsonl
1852        );
1853        assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
1854    }
1855
1856    #[test]
1857    fn redactor_does_not_leak_a_secret_across_deltas() {
1858        let mut redactor = SecretRedactor::new("secret");
1859        let mut output = Vec::new();
1860        redactor
1861            .push("prefix sec", |text| {
1862                output.push(text.to_owned());
1863                Ok(())
1864            })
1865            .expect("push");
1866        redactor
1867            .push("ret suffix", |text| {
1868                output.push(text.to_owned());
1869                Ok(())
1870            })
1871            .expect("push");
1872        redactor
1873            .finish(|text| {
1874                output.push(text.to_owned());
1875                Ok(())
1876            })
1877            .expect("finish");
1878        let output = output.join("");
1879        assert_eq!(
1880            output,
1881            format!("prefix {} suffix", redaction_marker("secret").unwrap())
1882        );
1883        assert!(!output.contains("secret"));
1884    }
1885
1886    #[test]
1887    fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1888        let mut redactor = SecretRedactor::new("n0");
1889        let mut output = String::new();
1890        redactor
1891            .push("\n0", |text| {
1892                output.push_str(text);
1893                Ok(())
1894            })
1895            .expect("push");
1896        redactor
1897            .finish(|text| {
1898                output.push_str(text);
1899                Ok(())
1900            })
1901            .expect("finish");
1902        assert!(!output.contains("n0"));
1903        assert_eq!(output, redaction_marker("n0").unwrap());
1904    }
1905
1906    #[test]
1907    fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1908        let mut redactor = SecretRedactor::new("secret");
1909        let mut output = Vec::new();
1910        redactor
1911            .push("xsecre", |text| {
1912                output.push(text.to_owned());
1913                Ok(())
1914            })
1915            .expect("first delta");
1916        redactor
1917            .push("t", |text| {
1918                output.push(text.to_owned());
1919                Ok(())
1920            })
1921            .expect("second delta");
1922        redactor
1923            .finish(|text| {
1924                output.push(text.to_owned());
1925                Ok(())
1926            })
1927            .expect("finish");
1928        let output = output.join("");
1929        assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1930        assert!(!output.contains("secret"));
1931    }
1932
1933    #[test]
1934    fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1935        for secret in ["REDACTED", "[REDACTED]"] {
1936            let mut redactor = SecretRedactor::new(secret);
1937            let split = secret.len() / 2;
1938            let (first, second) = secret.split_at(split);
1939            let mut output = String::new();
1940            redactor
1941                .push(first, |text| {
1942                    output.push_str(text);
1943                    Ok(())
1944                })
1945                .expect("first delta");
1946            redactor
1947                .push(second, |text| {
1948                    output.push_str(text);
1949                    Ok(())
1950                })
1951                .expect("second delta");
1952            redactor
1953                .finish(|text| {
1954                    output.push_str(text);
1955                    Ok(())
1956                })
1957                .expect("finish");
1958            assert!(!output.contains(secret));
1959            assert!(output.len() <= secret.len());
1960        }
1961    }
1962
1963    #[test]
1964    fn malformed_tool_arguments_use_a_safe_copy() {
1965        let secret = "provider-secret";
1966        let escaped = secret
1967            .chars()
1968            .map(|character| format!(r#"\u{:04x}"#, character as u32))
1969            .collect::<String>();
1970        let arguments = format!(r#"{{"command":"{escaped}""#);
1971        let safe = redact_tool_arguments(&arguments, secret);
1972        assert_eq!(safe, "{}");
1973        serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1974        assert!(!safe.contains(secret));
1975        assert!(!safe.contains(&escaped));
1976        for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1977            assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1978        }
1979    }
1980
1981    #[test]
1982    fn structured_redaction_preserves_tool_and_result_schema_keys() {
1983        let secret = "provider-secret";
1984        let value = serde_json::json!({
1985            "command": "printf provider-secret",
1986            "stdout": "provider-secret",
1987            "stderr": "ordinary",
1988            "exit_code": 0,
1989            "timed_out": false,
1990            "stdout_truncated": false,
1991            "stderr_truncated": false,
1992            "unknown-provider-secret": "provider-secret"
1993        });
1994        let redacted = redact_json_value(value, secret);
1995        for key in [
1996            "command",
1997            "stdout",
1998            "stderr",
1999            "exit_code",
2000            "timed_out",
2001            "stdout_truncated",
2002            "stderr_truncated",
2003        ] {
2004            assert!(redacted.get(key).is_some(), "missing schema key: {key}");
2005        }
2006        let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
2007        assert!(!encoded.contains(secret));
2008        assert!(redacted.get("unknown-provider-secret").is_none());
2009    }
2010
2011    #[test]
2012    fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
2013        let value = serde_json::json!({
2014            "exit_code": 0,
2015            "timed_out": false,
2016            "stdout_truncated": true,
2017            "error": null,
2018        });
2019        let redacted = redact_json_value(value, "0");
2020        assert!(redacted["exit_code"].is_number());
2021        assert!(redacted["timed_out"].is_boolean());
2022        assert!(redacted["stdout_truncated"].is_boolean());
2023        assert!(redacted["error"].is_null());
2024    }
2025
2026    #[test]
2027    fn reasoning_details_are_recursively_redacted_before_persistence() {
2028        let details = vec![serde_json::json!({
2029            "type": "reasoning.text",
2030            "text": "provider-secret",
2031            "nested": [{"value": "provider-secret"}],
2032            "provider-secret": "provider-secret"
2033        })];
2034        let redacted = redact_reasoning_details(&details, "provider-secret")
2035            .expect("non-empty reasoning details");
2036        let redacted = Value::Array(redacted);
2037        let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
2038        assert!(!encoded.contains("provider-secret"));
2039        assert_eq!(redacted[0]["type"], "reasoning.text");
2040        assert_eq!(redacted[0]["text"], "[REDACTED]");
2041        assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
2042        assert!(redacted[0].get("provider-secret").is_none());
2043    }
2044
2045    #[test]
2046    fn malformed_input_error_does_not_echo_secret_bearing_input() {
2047        let error =
2048            parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
2049                .expect_err("invalid input");
2050        assert!(!error.contains("provider-secret"));
2051    }
2052
2053    #[test]
2054    fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
2055        let mut output = Vec::new();
2056        let error = parse_input_message("not json").expect_err("invalid input");
2057        let mut protocol = ProtocolWriter::new(&mut output);
2058        protocol.error(&error).expect("error event");
2059        assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
2060        let _ = Cursor::new("");
2061    }
2062
2063    #[test]
2064    fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
2065        let secret = "lucy";
2066        let mut diagnostics = Vec::new();
2067        write_diagnostic_safe_with_environment(
2068            &mut diagnostics,
2069            secret,
2070            None,
2071            vec![secret.to_owned()],
2072        );
2073        let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
2074        assert!(!diagnostics.contains(secret));
2075    }
2076    #[test]
2077    fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
2078        let sources = vec![
2079            InstructionSource {
2080                path: std::path::PathBuf::from("/project/AGENTS.md"),
2081                contents: "agents".to_owned(),
2082            },
2083            InstructionSource {
2084                path: std::path::PathBuf::from("/project/CLAUDE.md"),
2085                contents: "claude".to_owned(),
2086            },
2087            InstructionSource {
2088                path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
2089                contents: "agents".to_owned(),
2090            },
2091        ];
2092
2093        assert_eq!(
2094            attached_agents(sources, "secret"),
2095            vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
2096        );
2097    }
2098
2099    #[test]
2100    fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
2101        let skill = SkillEntry {
2102            name: "release-notes".to_owned(),
2103            description: "Writes release notes".to_owned(),
2104            path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
2105            contents: "# Release notes\nUse the template.".to_owned(),
2106            model_invocable: true,
2107        };
2108        let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
2109            .expect("skill command");
2110        assert!(expanded.text.contains("# Release notes"));
2111        assert!(expanded.text.contains("User: v1.2"));
2112        assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
2113        let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
2114        assert_eq!(ordinary.text, "ordinary message");
2115        assert_eq!(ordinary.attached_skill, None);
2116        assert_eq!(
2117            expand_skill_invocation("/missing", &[]).unwrap_err(),
2118            "unknown skill: missing"
2119        );
2120        assert_eq!(
2121            expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
2122            "unknown skill: skill:release-notes"
2123        );
2124    }
2125}