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