Skip to main content

lucy/
app.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use serde_json::{Map, Value};
6
7use crate::config::{Config, DEFAULT_API_KEY_ENV};
8use crate::context::{resolve_boot_context_with_api_key_env, InstructionSource, SkillEntry};
9use crate::model::{estimate_context_tokens, estimate_message_tokens, ChatMessage, ChatToolCall};
10use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
11use crate::provider::{Provider, ProviderTurn};
12use crate::redaction::{
13    conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key, redact_secret,
14    redaction_marker,
15};
16use crate::session::Session;
17
18#[derive(Debug)]
19struct CliOptions {
20    session: Option<String>,
21    list_sessions: bool,
22    jsonl: bool,
23    tui: bool,
24}
25
26#[derive(Debug, Deserialize)]
27struct InputRecord {
28    #[serde(rename = "type")]
29    record_type: String,
30    text: Option<String>,
31}
32
33const MAX_TOOL_ROUNDS: usize = 32;
34const MAX_TOOL_CALLS_PER_MESSAGE: usize = 64;
35const USER_CANCEL_REASON: &str = "user_cancelled";
36const PROVIDER_PHASE: &str = "provider_stream";
37const COMMAND_PHASE: &str = "cmd";
38const AUTO_COMPACTION_THRESHOLD_PERCENT: usize = 95;
39const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
40const 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.";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FrontendMode {
44    Jsonl,
45    Tui,
46}
47
48pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
49where
50    R: BufRead,
51    W: Write,
52    E: Write,
53{
54    let home = match home_directory() {
55        Ok(home) => home,
56        Err(error) => {
57            let mut diagnostics = diagnostics;
58            write_diagnostic(&mut diagnostics, &error);
59            return 1;
60        }
61    };
62    let cwd = match std::env::current_dir() {
63        Ok(cwd) => cwd,
64        Err(_error) => {
65            let mut diagnostics = diagnostics;
66            write_diagnostic(&mut diagnostics, "unable to resolve cwd");
67            return 1;
68        }
69    };
70    run_cli_at_home_with_terminals(
71        args,
72        input,
73        output,
74        diagnostics,
75        &home,
76        &cwd,
77        io::stdin().is_terminal(),
78        io::stdout().is_terminal(),
79    )
80}
81
82pub fn run_cli_at_home<R, W, E>(
83    args: &[String],
84    input: R,
85    output: W,
86    diagnostics: E,
87    home: &Path,
88    cwd: &Path,
89) -> i32
90where
91    R: BufRead,
92    W: Write,
93    E: Write,
94{
95    // The generic test/library entry point has no terminal handles. The real
96    // binary uses run_cli, which supplies the actual stdio terminal state.
97    run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
98}
99
100#[allow(clippy::too_many_arguments)]
101fn run_cli_at_home_with_terminals<R, W, E>(
102    args: &[String],
103    input: R,
104    output: W,
105    mut diagnostics: E,
106    home: &Path,
107    cwd: &Path,
108    stdin_is_tty: bool,
109    stdout_is_tty: bool,
110) -> i32
111where
112    R: BufRead,
113    W: Write,
114    E: Write,
115{
116    let options = match parse_args(args) {
117        Ok(options) => options,
118        Err(error) => {
119            let mut diagnostics = diagnostics;
120            write_diagnostic(&mut diagnostics, &error);
121            return 2;
122        }
123    };
124    let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
125        Ok(mode) => mode,
126        Err(error) => {
127            write_diagnostic(&mut diagnostics, &error);
128            return 2;
129        }
130    };
131
132    if options.list_sessions {
133        let mut protocol = ProtocolWriter::new(output);
134        if let Err(error) = Config::ensure_exists(home) {
135            write_diagnostic(&mut diagnostics, &error.to_string());
136            return 1;
137        }
138        return match Session::list(home) {
139            Ok(sessions) => {
140                for session in sessions {
141                    if let Err(error) = protocol.emit_serializable(&session) {
142                        write_diagnostic(
143                            &mut diagnostics,
144                            &format!("unable to write session metadata: {error}"),
145                        );
146                        return 1;
147                    }
148                }
149                0
150            }
151            Err(error) => {
152                write_diagnostic(&mut diagnostics, &error.to_string());
153                1
154            }
155        };
156    }
157
158    let (session, provider, resumed, attached_agents) = if let Some(id) = options.session.as_deref()
159    {
160        let session = match Session::resume(home, id) {
161            Ok(session) => session,
162            Err(error) => {
163                write_diagnostic(&mut diagnostics, &error.to_string());
164                return 1;
165            }
166        };
167        let provider = match Provider::new(&session.llm) {
168            Ok(provider) => provider,
169            Err(error) => {
170                write_diagnostic(&mut diagnostics, &error.to_string());
171                return 1;
172            }
173        };
174        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
175            write_diagnostic_safe(
176                &mut diagnostics,
177                "API key conflicts with terminal UI literals",
178                Some(provider.api_key()),
179            );
180            return 1;
181        }
182        (session, provider, true, Vec::new())
183    } else {
184        let config = match Config::load_or_create(home) {
185            Ok(config) => config,
186            Err(error) => {
187                write_diagnostic(&mut diagnostics, &error.to_string());
188                return 1;
189            }
190        };
191        let configured_secret = configured_api_key(&config);
192        let api_key_env = configured_api_key_env(&config);
193        let llm = match config.resolved_llm() {
194            Ok(llm) => llm,
195            Err(error) => {
196                write_diagnostic_safe(
197                    &mut diagnostics,
198                    &error.to_string(),
199                    configured_secret.as_deref(),
200                );
201                return 1;
202            }
203        };
204        let provider = match Provider::new(&llm) {
205            Ok(provider) => provider,
206            Err(error) => {
207                write_diagnostic_safe(
208                    &mut diagnostics,
209                    &error.to_string(),
210                    configured_secret.as_deref(),
211                );
212                return 1;
213            }
214        };
215        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
216            write_diagnostic_safe(
217                &mut diagnostics,
218                "API key conflicts with terminal UI literals",
219                Some(provider.api_key()),
220            );
221            return 1;
222        }
223        let safe_cwd = match std::fs::canonicalize(cwd) {
224            Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
225            Ok(_) => {
226                write_diagnostic_safe(
227                    &mut diagnostics,
228                    "session header rejected",
229                    Some(provider.api_key()),
230                );
231                return 1;
232            }
233            Err(_) => {
234                write_diagnostic_safe(
235                    &mut diagnostics,
236                    "unable to resolve session cwd",
237                    Some(provider.api_key()),
238                );
239                return 1;
240            }
241        };
242        let context = match resolve_boot_context_with_api_key_env(
243            home,
244            &safe_cwd,
245            &config.system_prompt,
246            api_key_env.as_deref(),
247        ) {
248            Ok(context) => context,
249            Err(error) => {
250                write_diagnostic_safe(
251                    &mut diagnostics,
252                    &error.to_string(),
253                    configured_secret.as_deref(),
254                );
255                return 1;
256            }
257        };
258        let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
259        let attached_agents = attached_agents(context.instruction_files, provider.api_key());
260        let skills = redact_skills(context.skills, provider.api_key());
261        let session = match Session::create_with_skills_and_secret(
262            home,
263            &safe_cwd,
264            boot_system_prompt,
265            llm,
266            skills,
267            Some(provider.api_key()),
268        ) {
269            Ok(session) => session,
270            Err(error) => {
271                write_diagnostic_safe(
272                    &mut diagnostics,
273                    &error.to_string(),
274                    Some(provider.api_key()),
275                );
276                return 1;
277            }
278        };
279        (session, provider, false, attached_agents)
280    };
281
282    let harness = Harness {
283        session,
284        provider,
285        context_window: None,
286        attached_agents,
287    };
288    if mode == FrontendMode::Tui {
289        return match crate::tui::run(harness, resumed, output) {
290            Ok(()) => 0,
291            Err(error) => {
292                write_diagnostic(&mut diagnostics, &error);
293                1
294            }
295        };
296    }
297
298    let mut protocol = ProtocolWriter::new(output);
299    let mut harness = harness;
300    if let Err(error) = protocol.session(&harness.session.id, resumed) {
301        write_diagnostic_safe(
302            &mut diagnostics,
303            &format!("unable to write session event: {error}"),
304            Some(harness.provider.api_key()),
305        );
306        return 1;
307    }
308
309    for line in input.lines() {
310        let line = match line {
311            Ok(line) => line,
312            Err(error) => {
313                write_diagnostic_safe(
314                    &mut diagnostics,
315                    &format!("unable to read stdin: {error}"),
316                    Some(harness.provider.api_key()),
317                );
318                return 1;
319            }
320        };
321        if line.trim().is_empty() {
322            continue;
323        }
324        let text = match parse_input_message(&line) {
325            Ok(text) => text,
326            Err(error) => {
327                let error = redact_secret(&error, Some(harness.provider.api_key()));
328                if let Err(write_error) = protocol.error(&error) {
329                    write_diagnostic_safe(
330                        &mut diagnostics,
331                        &format!("unable to write protocol error: {write_error}"),
332                        Some(harness.provider.api_key()),
333                    );
334                    return 1;
335                }
336                continue;
337            }
338        };
339        if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
340            let error = redact_secret(&error, Some(harness.provider.api_key()));
341            if let Err(write_error) = protocol.error(&error) {
342                write_diagnostic_safe(
343                    &mut diagnostics,
344                    &format!("unable to write protocol error: {write_error}"),
345                    Some(harness.provider.api_key()),
346                );
347                return 1;
348            }
349        }
350    }
351
352    0
353}
354
355pub fn resolve_mode(
356    args: &[String],
357    stdin_is_tty: bool,
358    stdout_is_tty: bool,
359) -> Result<FrontendMode, String> {
360    let options = parse_args(args)?;
361    if options.list_sessions {
362        if options.tui {
363            return Err("--tui cannot be combined with --list-sessions".to_owned());
364        }
365        return Ok(FrontendMode::Jsonl);
366    }
367    if options.tui && !(stdin_is_tty && stdout_is_tty) {
368        return Err("--tui requires a terminal on stdin and stdout".to_owned());
369    }
370    if options.tui {
371        Ok(FrontendMode::Tui)
372    } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
373        Ok(FrontendMode::Jsonl)
374    } else {
375        Ok(FrontendMode::Tui)
376    }
377}
378
379pub(crate) struct Harness {
380    pub(crate) session: Session,
381    pub(crate) provider: Provider,
382    /// Model context metadata resolved by the interactive frontend; `None`
383    /// keeps compaction disabled when an OpenAI-compatible provider exposes no
384    /// context-window metadata.
385    pub(crate) context_window: Option<usize>,
386    /// AGENTS.md sources selected for this newly created session's boot context.
387    /// The TUI uses these only while its first-boot welcome is visible.
388    pub(crate) attached_agents: Vec<String>,
389}
390
391fn should_compact_context(context_tokens: usize, context_window: usize) -> bool {
392    context_window > 0
393        && context_tokens as u128 * 100
394            >= context_window as u128 * AUTO_COMPACTION_THRESHOLD_PERCENT as u128
395}
396
397fn find_compaction_boundary(
398    messages: &[ChatMessage],
399    previous_boundary: Option<usize>,
400) -> Option<usize> {
401    let user_starts = messages
402        .iter()
403        .enumerate()
404        .filter_map(|(index, message)| (message.role == "user").then_some(index))
405        .collect::<Vec<_>>();
406    let mut start = *user_starts.last()?;
407    let end = messages.len();
408    let mut kept_tokens = messages[start..end]
409        .iter()
410        .map(estimate_message_tokens)
411        .sum::<usize>();
412
413    while kept_tokens < COMPACTION_KEEP_RECENT_TOKENS {
414        let Some(previous_start) = user_starts
415            .iter()
416            .copied()
417            .rev()
418            .find(|candidate| *candidate < start)
419        else {
420            break;
421        };
422        start = previous_start;
423        kept_tokens = messages[start..end]
424            .iter()
425            .map(estimate_message_tokens)
426            .sum::<usize>();
427    }
428
429    (start > 0 && previous_boundary.is_none_or(|previous| start > previous)).then_some(start)
430}
431
432impl Harness {
433    fn should_compact(&self, messages: &[ChatMessage]) -> bool {
434        self.context_window
435            .is_some_and(|window| should_compact_context(estimate_context_tokens(messages), window))
436    }
437
438    fn compaction_boundary(&self) -> Option<usize> {
439        let latest_boundary = self
440            .session
441            .history
442            .iter()
443            .rev()
444            .find_map(|record| match record {
445                crate::session::SessionHistoryRecord::Compaction(compaction) => {
446                    Some(compaction.first_kept_message)
447                }
448                _ => None,
449            });
450        find_compaction_boundary(&self.session.messages, latest_boundary)
451    }
452
453    fn compact_context<S: EventSink>(
454        &mut self,
455        sink: &mut S,
456        cancellation: Option<&crate::cancellation::CancellationToken>,
457        tokens_before: usize,
458    ) -> Result<(), String> {
459        let Some(boundary) = self.compaction_boundary() else {
460            return Err("context cannot be compacted without an earlier complete turn".to_owned());
461        };
462        let Some(cancellation) = cancellation else {
463            return Err("context compaction requires a cancellable turn".to_owned());
464        };
465        sink.compaction_started()
466            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
467        let context_messages = self.session.provider_messages();
468        let mut summary_messages = Vec::with_capacity(context_messages.len() + 1);
469        summary_messages.push(ChatMessage::system(self.session.boot_system_prompt.clone()));
470        summary_messages.push(ChatMessage::system(COMPACTION_SYSTEM_PROMPT.to_owned()));
471        summary_messages.extend(context_messages.into_iter().skip(1));
472        let summary = match self.provider.summarize(&summary_messages, cancellation) {
473            Ok(summary) => redact_secret(&summary, Some(self.provider.api_key())),
474            Err(error) if cancellation.is_cancelled() || error.is_cancelled() => {
475                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
476            }
477            Err(error) => return Err(format!("unable to compact context: {error}")),
478        };
479        self.session
480            .append_compaction(summary, boundary, tokens_before)
481            .map_err(|error| format!("unable to persist context compaction: {error}"))?;
482        let tokens_after = estimate_context_tokens(&self.session.provider_messages());
483        sink.compaction_finished(tokens_before, tokens_after)
484            .map_err(|error| format!("unable to emit compaction state: {error}"))?;
485        Ok(())
486    }
487
488    pub(crate) fn handle_message<S: EventSink>(
489        &mut self,
490        text: &str,
491        sink: &mut S,
492        cancellation: Option<&crate::cancellation::CancellationToken>,
493    ) -> Result<(), String> {
494        let secret = self.provider.api_key().to_owned();
495        let expanded = expand_skill_invocation(text, &self.session.skills)?;
496        let user_message = ChatMessage::user(redact_secret(&expanded.text, Some(&secret)));
497        if let Err(error) = self.session.append_message(user_message) {
498            if cancellation.is_some_and(|token| token.is_cancelled()) {
499                let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
500                return interruption
501                    .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
502            }
503            return Err(error.to_string());
504        }
505        if let Some(name) = expanded.attached_skill.as_deref() {
506            sink.skill_instruction_attached(name)
507                .map_err(|error| format!("unable to emit skill attachment state: {error}"))?;
508        }
509
510        let mut tool_rounds = 0;
511        let mut tool_calls: usize = 0;
512        let mut compacted_for_turn = false;
513        loop {
514            if cancellation.is_some_and(|token| token.is_cancelled()) {
515                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
516            }
517            if tool_rounds >= MAX_TOOL_ROUNDS {
518                return Err(format!(
519                    "tool loop exceeded maximum of {MAX_TOOL_ROUNDS} provider rounds"
520                ));
521            }
522            let mut messages = self.session.provider_messages();
523            let tokens_before = estimate_context_tokens(&messages);
524            if !compacted_for_turn && self.should_compact(&messages) {
525                self.compact_context(sink, cancellation, tokens_before)?;
526                compacted_for_turn = true;
527                messages = self.session.provider_messages();
528            }
529            sink.context_usage(estimate_context_tokens(&messages))
530                .map_err(|error| format!("unable to emit context usage: {error}"))?;
531            let mut raw_content = String::new();
532            let mut redactor = SecretRedactor::new(&secret);
533            let stream_result = {
534                let mut on_text = |delta: &str| {
535                    raw_content.push_str(delta);
536                    redactor.push(delta, |safe_delta| {
537                        sink.emit_event(&ProtocolEvent::AssistantDelta {
538                            text: safe_delta.to_owned(),
539                        })
540                    })
541                };
542                match cancellation {
543                    Some(token) => {
544                        self.provider
545                            .stream_chat_cancellable(&messages, &mut on_text, token)
546                    }
547                    None => self.provider.stream_chat(&messages, &mut on_text),
548                }
549            };
550            redactor
551                .finish(|safe_delta| {
552                    sink.emit_event(&ProtocolEvent::AssistantDelta {
553                        text: safe_delta.to_owned(),
554                    })
555                })
556                .map_err(|error| format!("unable to write assistant delta: {error}"))?;
557            let turn = match stream_result {
558                Ok(turn) => turn,
559                Err(error)
560                    if cancellation.is_some_and(|token| token.is_cancelled())
561                        || error.is_cancelled() =>
562                {
563                    let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
564                        content: raw_content,
565                        tool_calls: Vec::new(),
566                        reasoning_details: Vec::new(),
567                    });
568                    return self.interrupt(
569                        sink,
570                        PROVIDER_PHASE,
571                        &partial.content,
572                        &partial.tool_calls,
573                        Vec::new(),
574                    );
575                }
576                Err(error) => return Err(error.to_string()),
577            };
578            let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
579            if !turn.reasoning_details.is_empty() {
580                sink.reasoning_started()
581                    .map_err(|error| format!("unable to emit reasoning state: {error}"))?;
582            }
583
584            if turn.tool_calls.iter().any(|call| call.name != "cmd") {
585                if canceled_after_stream {
586                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
587                }
588                return Err("provider requested an unsupported tool".to_owned());
589            }
590            if tool_calls.saturating_add(turn.tool_calls.len()) > MAX_TOOL_CALLS_PER_MESSAGE {
591                if canceled_after_stream {
592                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
593                }
594                return Err(format!(
595                    "tool call budget exceeded maximum of {MAX_TOOL_CALLS_PER_MESSAGE} calls per input message"
596                ));
597            }
598
599            let safe_tool_calls = turn
600                .tool_calls
601                .iter()
602                .map(|call| ChatToolCall {
603                    id: redact_secret(&call.id, Some(&secret)),
604                    name: redact_secret(&call.name, Some(&secret)),
605                    arguments: redact_tool_arguments(&call.arguments, &secret),
606                })
607                .collect::<Vec<_>>();
608            let assistant_content = redact_secret(&turn.content, Some(&secret));
609            let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
610            let mut assistant =
611                ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
612            assistant.reasoning_details = safe_reasoning_details;
613            if let Err(error) = self.session.append_message(assistant) {
614                if cancellation.is_some_and(|token| token.is_cancelled()) {
615                    let interruption = self.interrupt(
616                        sink,
617                        PROVIDER_PHASE,
618                        &assistant_content,
619                        &turn.tool_calls,
620                        Vec::new(),
621                    );
622                    return interruption
623                        .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
624                }
625                return Err(error.to_string());
626            }
627
628            if safe_tool_calls.is_empty() {
629                if canceled_after_stream || cancellation.is_some_and(|token| !token.try_complete())
630                {
631                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
632                }
633                sink.context_usage(estimate_context_tokens(&self.session.provider_messages()))
634                    .map_err(|error| format!("unable to emit context usage: {error}"))?;
635                sink.emit_event(&ProtocolEvent::TurnEnd)
636                    .map_err(|error| format!("unable to write turn end: {error}"))?;
637                return Ok(());
638            }
639
640            tool_rounds += 1;
641            tool_calls += safe_tool_calls.len();
642            for safe_call in &safe_tool_calls {
643                sink.emit_event(&ProtocolEvent::ToolCall {
644                    id: safe_call.id.clone(),
645                    name: safe_call.name.clone(),
646                    arguments: safe_call.arguments.clone(),
647                })
648                .map_err(|error| format!("unable to write tool call: {error}"))?;
649            }
650            for index in 0..turn.tool_calls.len() {
651                let raw_call = &turn.tool_calls[index];
652                let safe_call = &safe_tool_calls[index];
653                let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
654                    crate::command::canceled_result(&safe_call.arguments, &secret)
655                } else {
656                    crate::command::execute_with_cancellation(
657                        &raw_call.arguments,
658                        &self.session.cwd,
659                        self.provider.api_key_env(),
660                        Some(&secret),
661                        cancellation,
662                    )
663                };
664                let result = redact_json_value(
665                    serde_json::to_value(result)
666                        .map_err(|error| format!("unable to encode cmd result: {error}"))?,
667                    &secret,
668                );
669                let tool_content = serde_json::to_string(&result)
670                    .map_err(|error| format!("unable to encode tool result: {error}"))?;
671                let tool_message = ChatMessage::tool(
672                    safe_call.id.clone(),
673                    safe_call.name.clone(),
674                    redact_secret(&tool_content, Some(&secret)),
675                );
676                let observation = crate::session::SessionToolResult {
677                    id: safe_call.id.clone(),
678                    name: safe_call.name.clone(),
679                    result: result.clone(),
680                };
681                if let Err(error) = self.session.append_message(tool_message) {
682                    if cancellation.is_some_and(|token| token.is_cancelled()) {
683                        let interruption =
684                            self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
685                        return interruption
686                            .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
687                    }
688                    return Err(error.to_string());
689                }
690                sink.emit_event(&ProtocolEvent::ToolResult {
691                    id: safe_call.id.clone(),
692                    name: safe_call.name.clone(),
693                    result: result.clone(),
694                })
695                .map_err(|error| format!("unable to write tool result: {error}"))?;
696                if cancellation.is_some_and(|token| token.is_cancelled()) {
697                    for pending_call in safe_tool_calls.iter().skip(index + 1) {
698                        let pending_result = redact_json_value(
699                            serde_json::to_value(crate::command::canceled_result(
700                                &pending_call.arguments,
701                                &secret,
702                            ))
703                            .map_err(|error| format!("unable to encode cmd result: {error}"))?,
704                            &secret,
705                        );
706                        let pending_content = serde_json::to_string(&pending_result)
707                            .map_err(|error| format!("unable to encode tool result: {error}"))?;
708                        let pending_message = ChatMessage::tool(
709                            pending_call.id.clone(),
710                            pending_call.name.clone(),
711                            redact_secret(&pending_content, Some(&secret)),
712                        );
713                        let pending_observation = crate::session::SessionToolResult {
714                            id: pending_call.id.clone(),
715                            name: pending_call.name.clone(),
716                            result: pending_result.clone(),
717                        };
718                        if let Err(error) = self.session.append_message(pending_message) {
719                            if cancellation.is_some_and(|token| token.is_cancelled()) {
720                                let interruption = self.interrupt(
721                                    sink,
722                                    COMMAND_PHASE,
723                                    "",
724                                    &[],
725                                    vec![pending_observation],
726                                );
727                                return interruption.map_err(|interrupt_error| {
728                                    format!("{error}; {interrupt_error}")
729                                });
730                            }
731                            return Err(error.to_string());
732                        }
733                        sink.emit_event(&ProtocolEvent::ToolResult {
734                            id: pending_call.id.clone(),
735                            name: pending_call.name.clone(),
736                            result: pending_result.clone(),
737                        })
738                        .map_err(|error| format!("unable to write tool result: {error}"))?;
739                    }
740                    return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
741                }
742            }
743        }
744    }
745
746    fn interrupt<S: EventSink>(
747        &mut self,
748        sink: &mut S,
749        phase: &str,
750        assistant_text: &str,
751        tool_calls: &[ChatToolCall],
752        tool_results: Vec<crate::session::SessionToolResult>,
753    ) -> Result<(), String> {
754        let secret = self.provider.api_key();
755        let safe_tool_calls = tool_calls
756            .iter()
757            .filter(|call| call.name == "cmd")
758            .map(|call| safe_partial_tool_call(call, secret))
759            .collect::<Vec<_>>();
760        let safe_tool_results = tool_results.clone();
761        let interruption = crate::session::InterruptionRecord {
762            timestamp: 0,
763            reason: USER_CANCEL_REASON.to_owned(),
764            phase: phase.to_owned(),
765            assistant_text: redact_secret(assistant_text, Some(secret)),
766            tool_calls: safe_tool_calls.clone(),
767            tool_results,
768        };
769        let persistence_error = self.session.append_interruption(interruption).err();
770        let mut event_error = None;
771        for call in &safe_tool_calls {
772            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
773                id: call.id.clone(),
774                name: call.name.clone(),
775                arguments: call.arguments.clone(),
776            }) {
777                event_error.get_or_insert(error);
778            }
779        }
780        for observation in &safe_tool_results {
781            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
782                id: observation.id.clone(),
783                name: observation.name.clone(),
784                result: observation.result.clone(),
785            }) {
786                event_error.get_or_insert(error);
787            }
788        }
789        if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
790            reason: USER_CANCEL_REASON.to_owned(),
791            phase: phase.to_owned(),
792        }) {
793            event_error.get_or_insert(error);
794        }
795        match (persistence_error, event_error) {
796            (None, None) => Ok(()),
797            (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
798            (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
799            (Some(persistence), Some(event)) => Err(format!(
800                "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
801            )),
802        }
803    }
804}
805
806struct SecretRedactor {
807    secret_text: String,
808    secret: Vec<char>,
809    marker: String,
810    pending: String,
811}
812
813impl SecretRedactor {
814    fn new(secret: &str) -> Self {
815        Self {
816            secret_text: secret.to_owned(),
817            secret: secret.chars().collect(),
818            marker: redaction_marker(secret).unwrap_or_default(),
819            pending: String::new(),
820        }
821    }
822
823    fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
824    where
825        F: FnMut(&str) -> io::Result<()>,
826    {
827        if self.secret.is_empty() {
828            return emit(text);
829        }
830
831        let mut output = String::new();
832        for character in text.chars() {
833            self.pending.push(character);
834            if self.pending.chars().eq(self.secret.iter().copied()) {
835                self.pending.clear();
836                output.push_str(&self.marker);
837                continue;
838            }
839            if self.pending_is_secret_prefix() {
840                continue;
841            }
842
843            let pending = self.pending.chars().collect::<Vec<_>>();
844            let suffix_len = (1..pending.len())
845                .rev()
846                .find(|length| {
847                    pending[pending.len() - length..].iter().copied().eq(self
848                        .secret
849                        .iter()
850                        .copied()
851                        .take(*length))
852                })
853                .unwrap_or(0);
854            let safe_len = pending.len() - suffix_len;
855            output.extend(pending[..safe_len].iter());
856            self.pending = pending[safe_len..].iter().collect();
857        }
858
859        if output.is_empty() {
860            Ok(())
861        } else {
862            let safe_output = redact_secret(&output, Some(&self.secret_text));
863            emit(&safe_output)
864        }
865    }
866
867    fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
868    where
869        F: FnMut(&str) -> io::Result<()>,
870    {
871        let pending = std::mem::take(&mut self.pending);
872        if pending.is_empty() {
873            return Ok(());
874        }
875        let safe_pending = redact_secret(&pending, Some(&self.secret_text));
876        emit(&safe_pending)
877    }
878
879    fn pending_is_secret_prefix(&self) -> bool {
880        let length = self.pending.chars().count();
881        length < self.secret.len()
882            && self
883                .pending
884                .chars()
885                .zip(self.secret.iter().copied())
886                .all(|(pending, secret)| pending == secret)
887    }
888}
889
890/// Return the AGENTS.md files selected for the current new-session boot context.
891/// Paths are secret-redacted before they can reach the terminal UI.
892fn attached_agents(instruction_files: Vec<InstructionSource>, secret: &str) -> Vec<String> {
893    instruction_files
894        .into_iter()
895        .filter(|source| {
896            source
897                .path
898                .file_name()
899                .is_some_and(|name| name == "AGENTS.md")
900        })
901        .map(|source| redact_secret(&source.path.display().to_string(), Some(secret)))
902        .collect()
903}
904
905/// Store a secret-safe skill snapshot with the session. The source is read
906/// once during secure context discovery; later invocations never follow paths.
907fn escape_xml_attribute(text: &str) -> String {
908    text.replace('&', "&amp;")
909        .replace('<', "&lt;")
910        .replace('>', "&gt;")
911        .replace('\"', "&quot;")
912        .replace('\'', "&apos;")
913}
914
915fn redact_skills(skills: Vec<SkillEntry>, secret: &str) -> Vec<SkillEntry> {
916    skills
917        .into_iter()
918        .map(|skill| SkillEntry {
919            name: redact_secret(&skill.name, Some(secret)),
920            description: redact_secret(&skill.description, Some(secret)),
921            path: std::path::PathBuf::from(redact_secret(
922                &skill.path.display().to_string(),
923                Some(secret),
924            )),
925            contents: redact_secret(&skill.contents, Some(secret)),
926            model_invocable: skill.model_invocable,
927        })
928        .collect()
929}
930
931/// The message delivered to the provider and the optional name of the saved
932/// skill snapshot that was attached to it.
933#[derive(Debug)]
934struct ExpandedSkillInvocation {
935    text: String,
936    attached_skill: Option<String>,
937}
938
939/// Expand slash-prefixed skill names into the user message sent to the
940/// provider. This deliberately adds no model-facing tool: skills are context,
941/// not an executable capability of their own.
942fn expand_skill_invocation(
943    text: &str,
944    skills: &[SkillEntry],
945) -> Result<ExpandedSkillInvocation, String> {
946    let Some(invocation) = text.strip_prefix('/') else {
947        return Ok(ExpandedSkillInvocation {
948            text: text.to_owned(),
949            attached_skill: None,
950        });
951    };
952    let mut pieces = invocation.splitn(2, char::is_whitespace);
953    let name = pieces.next().unwrap_or_default();
954    if name.is_empty() {
955        return Err("skill command requires a skill name: /<name> [args]".to_owned());
956    }
957    let Some(skill) = skills.iter().find(|skill| skill.name == name) else {
958        return Err(format!("unknown skill: {name}"));
959    };
960    let arguments = pieces.next().unwrap_or_default().trim();
961    let mut message = format!(
962        "<skill name=\"{}\" location=\"{}\">\n{}\n</skill>",
963        escape_xml_attribute(&skill.name),
964        escape_xml_attribute(&skill.path.display().to_string()),
965        skill.contents.trim()
966    );
967    if !arguments.is_empty() {
968        message.push_str("\n\nUser: ");
969        message.push_str(arguments);
970    }
971    Ok(ExpandedSkillInvocation {
972        text: message,
973        attached_skill: Some(skill.name.clone()),
974    })
975}
976
977fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
978    let fallback = || "{}".to_owned();
979    let Ok(value) = serde_json::from_str::<Value>(arguments) else {
980        return fallback();
981    };
982    let Some(object) = value.as_object() else {
983        return fallback();
984    };
985    if object.len() != 1 || !object.get("command").is_some_and(Value::is_string) {
986        return fallback();
987    }
988    let redacted = redact_json_value(value, secret);
989    serde_json::to_string(&redacted).unwrap_or_else(|_| fallback())
990}
991
992fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
993    let arguments = if serde_json::from_str::<Value>(&call.arguments)
994        .ok()
995        .and_then(|value| value.as_object().cloned())
996        .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
997    {
998        redact_tool_arguments(&call.arguments, secret)
999    } else {
1000        // An incomplete argument fragment is an observation only. Do not
1001        // preserve malformed provider JSON: decoding it later could expose a
1002        // credential that was hidden by the outer JSON string.
1003        "{}".to_owned()
1004    };
1005    ChatToolCall {
1006        id: redact_secret(&call.id, Some(secret)),
1007        name: redact_secret(&call.name, Some(secret)),
1008        arguments,
1009    }
1010}
1011
1012fn redact_json_value(value: Value, secret: &str) -> Value {
1013    match value {
1014        Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
1015        Value::Array(values) => Value::Array(
1016            values
1017                .into_iter()
1018                .map(|value| redact_json_value(value, secret))
1019                .collect(),
1020        ),
1021        Value::Object(object) => {
1022            let marker = redaction_marker(secret).unwrap_or_default();
1023            let mut redacted = Map::new();
1024            for (key, value) in object {
1025                let mut safe_key = if is_structural_key(&key) {
1026                    key
1027                } else {
1028                    redact_secret(&key, Some(secret))
1029                };
1030                if redacted.contains_key(&safe_key) {
1031                    if marker.is_empty() {
1032                        continue;
1033                    }
1034                    while redacted.contains_key(&safe_key) {
1035                        safe_key.push_str(&marker);
1036                    }
1037                }
1038                redacted.insert(safe_key, redact_json_value(value, secret));
1039            }
1040            Value::Object(redacted)
1041        }
1042        value => value,
1043    }
1044}
1045
1046fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
1047    if details.is_empty() {
1048        return None;
1049    }
1050    match redact_json_value(Value::Array(details.to_vec()), secret) {
1051        Value::Array(details) => Some(details),
1052        _ => None,
1053    }
1054}
1055
1056fn parse_args(args: &[String]) -> Result<CliOptions, String> {
1057    let mut options = CliOptions {
1058        session: None,
1059        list_sessions: false,
1060        jsonl: false,
1061        tui: false,
1062    };
1063    let mut index = 0;
1064    while index < args.len() {
1065        match args[index].as_str() {
1066            "--session" => {
1067                if options.list_sessions || options.session.is_some() {
1068                    return Err("--session cannot be combined or repeated".to_owned());
1069                }
1070                index += 1;
1071                let Some(id) = args.get(index) else {
1072                    return Err("--session requires an id".to_owned());
1073                };
1074                options.session = Some(id.clone());
1075            }
1076            "--list-sessions" => {
1077                if options.session.is_some() || options.list_sessions {
1078                    return Err("--list-sessions cannot be combined or repeated".to_owned());
1079                }
1080                options.list_sessions = true;
1081            }
1082            "--jsonl" => {
1083                if options.jsonl || options.tui {
1084                    return Err("--jsonl cannot be combined or repeated".to_owned());
1085                }
1086                options.jsonl = true;
1087            }
1088            "--tui" => {
1089                if options.tui || options.jsonl {
1090                    return Err("--tui cannot be combined or repeated".to_owned());
1091                }
1092                options.tui = true;
1093            }
1094            "--help" | "-h" => {
1095                return Err(
1096                    "usage: lucy [--jsonl|--tui] [--session <id>] [--list-sessions]".to_owned(),
1097                );
1098            }
1099            _ => return Err("unknown argument".to_owned()),
1100        }
1101        index += 1;
1102    }
1103    Ok(options)
1104}
1105
1106fn parse_input_message(line: &str) -> Result<String, String> {
1107    let record: InputRecord = serde_json::from_str(line)
1108        .map_err(|_| "input must be a JSONL message record".to_owned())?;
1109    if record.record_type != "message" {
1110        return Err("input record type must be message".to_owned());
1111    }
1112    record
1113        .text
1114        .ok_or_else(|| "message record requires a text string".to_owned())
1115}
1116
1117fn home_directory() -> Result<PathBuf, String> {
1118    std::env::var_os("HOME")
1119        .map(PathBuf::from)
1120        .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
1121}
1122
1123fn configured_api_key_env(config: &Config) -> Option<String> {
1124    let api_key_env = config
1125        .llm
1126        .api_key_env
1127        .as_deref()
1128        .unwrap_or(DEFAULT_API_KEY_ENV)
1129        .trim();
1130    (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
1131}
1132
1133fn configured_api_key(config: &Config) -> Option<String> {
1134    configured_api_key_env(config)
1135        .and_then(|api_key_env| std::env::var(api_key_env).ok())
1136        .filter(|secret| !secret.is_empty())
1137}
1138
1139fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
1140    write_diagnostic_safe_with_environment(
1141        diagnostics,
1142        message,
1143        secret,
1144        std::env::vars().map(|(_, value)| value),
1145    );
1146}
1147
1148fn write_diagnostic_safe_with_environment<W, I>(
1149    diagnostics: &mut W,
1150    message: &str,
1151    secret: Option<&str>,
1152    environment_values: I,
1153) where
1154    W: Write,
1155    I: IntoIterator<Item = String>,
1156{
1157    let mut safe_line = format!("!: {message}");
1158    safe_line = redact_secret(&safe_line, secret);
1159    let mut environment_secrets = environment_values
1160        .into_iter()
1161        .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
1162        .collect::<Vec<_>>();
1163    environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
1164    for environment_secret in environment_secrets {
1165        safe_line = redact_secret(&safe_line, Some(&environment_secret));
1166    }
1167    let _ = writeln!(diagnostics, "{safe_line}");
1168}
1169
1170fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
1171    write_diagnostic_safe(diagnostics, message, None);
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::*;
1177    use crate::cancellation::CancellationToken;
1178    use std::io::{Cursor, Read, Write};
1179    use std::net::TcpListener;
1180    use std::thread;
1181
1182    #[test]
1183    fn auto_compaction_triggers_at_or_above_ninety_five_percent_only() {
1184        assert!(!should_compact_context(94, 100));
1185        assert!(should_compact_context(95, 100));
1186        assert!(should_compact_context(96, 100));
1187        assert!(!should_compact_context(100, 0));
1188    }
1189
1190    #[test]
1191    fn compaction_boundary_keeps_complete_recent_turns() {
1192        let messages = [
1193            ChatMessage::user("old request".to_owned()),
1194            ChatMessage::assistant("old answer".to_owned(), Vec::new()),
1195            ChatMessage::user("recent request".to_owned()),
1196            ChatMessage::assistant("recent answer ".repeat(8_000), Vec::new()),
1197        ];
1198
1199        assert_eq!(find_compaction_boundary(&messages, None), Some(2));
1200        assert_eq!(find_compaction_boundary(&messages, Some(2)), None);
1201    }
1202
1203    #[test]
1204    fn mid_turn_compaction_summarizes_without_tools_then_continues_original_request() {
1205        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("compaction listener");
1206        let address = listener.local_addr().expect("compaction address");
1207        let responses = ["summary", "continued"];
1208        let server = thread::spawn(move || {
1209            let mut requests = Vec::new();
1210            for response_text in responses {
1211                let (mut stream, _) = listener.accept().expect("compaction request");
1212                let mut request = String::new();
1213                let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone"));
1214                let mut content_length = 0usize;
1215                loop {
1216                    let mut line = String::new();
1217                    reader.read_line(&mut line).expect("request header");
1218                    if line == "\r\n" {
1219                        break;
1220                    }
1221                    if let Some((name, value)) = line.split_once(':') {
1222                        if name.eq_ignore_ascii_case("content-length") {
1223                            content_length = value.trim().parse().expect("content length");
1224                        }
1225                    }
1226                }
1227                let mut body = vec![0u8; content_length];
1228                reader.read_exact(&mut body).expect("request body");
1229                request.push_str(std::str::from_utf8(&body).expect("request JSON"));
1230                requests.push(serde_json::from_str::<Value>(&request).expect("request value"));
1231                let payload = serde_json::json!({
1232                    "choices": [{
1233                        "delta": {"content": response_text},
1234                        "finish_reason": null
1235                    }]
1236                });
1237                let body = format!("data: {payload}\n\ndata: [DONE]\n\n");
1238                let header = format!(
1239                    "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1240                    body.len()
1241                );
1242                stream
1243                    .write_all(header.as_bytes())
1244                    .expect("response header");
1245                stream.write_all(body.as_bytes()).expect("response body");
1246                stream.flush().expect("response flush");
1247            }
1248            requests
1249        });
1250
1251        let key_env = format!("LUCY_COMPACTION_APP_KEY_{}", std::process::id());
1252        std::env::set_var(&key_env, "provider-secret");
1253        let settings = crate::config::LlmSettings {
1254            base_url: format!("http://{address}/v1"),
1255            model: "model".to_owned(),
1256            api_key_env: key_env.clone(),
1257            effort: None,
1258        };
1259        let provider = Provider::new(&settings).expect("provider");
1260        let home = std::env::temp_dir().join(format!("lucy-app-compaction-{}", std::process::id()));
1261        let _ = std::fs::remove_dir_all(&home);
1262        std::fs::create_dir(&home).expect("temp home");
1263        let cwd = std::env::current_dir().expect("cwd");
1264        let mut session = Session::create_with_secret(
1265            &home,
1266            &cwd,
1267            "prompt".to_owned(),
1268            settings,
1269            Some("provider-secret"),
1270        )
1271        .expect("session");
1272        session
1273            .append_message(ChatMessage::user("old request".to_owned()))
1274            .expect("old user");
1275        session
1276            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1277            .expect("old answer");
1278        session
1279            .append_message(ChatMessage::user("recent request".to_owned()))
1280            .expect("recent user");
1281        session
1282            .append_message(ChatMessage::assistant(
1283                "recent answer ".repeat(8_000),
1284                Vec::new(),
1285            ))
1286            .expect("recent answer");
1287
1288        struct Sink {
1289            events: Vec<ProtocolEvent>,
1290            compaction_started: bool,
1291            compaction_finished: bool,
1292        }
1293        impl EventSink for Sink {
1294            fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
1295                self.events.push(event.clone());
1296                Ok(())
1297            }
1298            fn compaction_started(&mut self) -> io::Result<()> {
1299                self.compaction_started = true;
1300                Ok(())
1301            }
1302            fn compaction_finished(&mut self, _: usize, _: usize) -> io::Result<()> {
1303                self.compaction_finished = true;
1304                Ok(())
1305            }
1306        }
1307
1308        let mut harness = Harness {
1309            session,
1310            provider,
1311            context_window: Some(1),
1312            attached_agents: Vec::new(),
1313        };
1314        let cancellation = CancellationToken::new();
1315        let mut sink = Sink {
1316            events: Vec::new(),
1317            compaction_started: false,
1318            compaction_finished: false,
1319        };
1320        harness
1321            .handle_message("continue", &mut sink, Some(&cancellation))
1322            .expect("continued turn");
1323
1324        let requests = server.join().expect("server");
1325        assert_eq!(requests.len(), 2);
1326        assert!(requests[0].get("tools").is_none());
1327        assert!(requests[1].get("tools").is_some());
1328        assert!(sink.compaction_started);
1329        assert!(sink.compaction_finished);
1330        assert!(sink.events.iter().any(
1331            |event| matches!(event, ProtocolEvent::AssistantDelta { text } if text == "continued")
1332        ));
1333        assert!(harness
1334            .session
1335            .history
1336            .iter()
1337            .any(|record| matches!(record, crate::session::SessionHistoryRecord::Compaction(_))));
1338        let provider_text = harness
1339            .session
1340            .provider_messages()
1341            .iter()
1342            .filter_map(|message| message.content.as_deref())
1343            .collect::<Vec<_>>()
1344            .join("\n");
1345        assert!(!provider_text.contains("old request"));
1346        assert!(provider_text.contains("continue"));
1347
1348        std::env::remove_var(key_env);
1349        std::fs::remove_dir_all(home).expect("cleanup");
1350    }
1351
1352    #[test]
1353    fn parses_only_message_records() {
1354        assert_eq!(
1355            parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
1356            "hello"
1357        );
1358        assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
1359        assert_eq!(
1360            parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
1361            ""
1362        );
1363    }
1364
1365    #[test]
1366    fn resolves_terminal_and_forced_modes() {
1367        assert_eq!(
1368            resolve_mode(&[], true, true).expect("default TUI"),
1369            FrontendMode::Tui
1370        );
1371        assert_eq!(
1372            resolve_mode(&[], true, false).expect("automatic JSONL"),
1373            FrontendMode::Jsonl
1374        );
1375        assert_eq!(
1376            resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
1377            FrontendMode::Jsonl
1378        );
1379        assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
1380    }
1381
1382    #[test]
1383    fn redactor_does_not_leak_a_secret_across_deltas() {
1384        let mut redactor = SecretRedactor::new("secret");
1385        let mut output = Vec::new();
1386        redactor
1387            .push("prefix sec", |text| {
1388                output.push(text.to_owned());
1389                Ok(())
1390            })
1391            .expect("push");
1392        redactor
1393            .push("ret suffix", |text| {
1394                output.push(text.to_owned());
1395                Ok(())
1396            })
1397            .expect("push");
1398        redactor
1399            .finish(|text| {
1400                output.push(text.to_owned());
1401                Ok(())
1402            })
1403            .expect("finish");
1404        let output = output.join("");
1405        assert_eq!(
1406            output,
1407            format!("prefix {} suffix", redaction_marker("secret").unwrap())
1408        );
1409        assert!(!output.contains("secret"));
1410    }
1411
1412    #[test]
1413    fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1414        let mut redactor = SecretRedactor::new("n0");
1415        let mut output = String::new();
1416        redactor
1417            .push("\n0", |text| {
1418                output.push_str(text);
1419                Ok(())
1420            })
1421            .expect("push");
1422        redactor
1423            .finish(|text| {
1424                output.push_str(text);
1425                Ok(())
1426            })
1427            .expect("finish");
1428        assert!(!output.contains("n0"));
1429        assert_eq!(output, redaction_marker("n0").unwrap());
1430    }
1431
1432    #[test]
1433    fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1434        let mut redactor = SecretRedactor::new("secret");
1435        let mut output = Vec::new();
1436        redactor
1437            .push("xsecre", |text| {
1438                output.push(text.to_owned());
1439                Ok(())
1440            })
1441            .expect("first delta");
1442        redactor
1443            .push("t", |text| {
1444                output.push(text.to_owned());
1445                Ok(())
1446            })
1447            .expect("second delta");
1448        redactor
1449            .finish(|text| {
1450                output.push(text.to_owned());
1451                Ok(())
1452            })
1453            .expect("finish");
1454        let output = output.join("");
1455        assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1456        assert!(!output.contains("secret"));
1457    }
1458
1459    #[test]
1460    fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1461        for secret in ["REDACTED", "[REDACTED]"] {
1462            let mut redactor = SecretRedactor::new(secret);
1463            let split = secret.len() / 2;
1464            let (first, second) = secret.split_at(split);
1465            let mut output = String::new();
1466            redactor
1467                .push(first, |text| {
1468                    output.push_str(text);
1469                    Ok(())
1470                })
1471                .expect("first delta");
1472            redactor
1473                .push(second, |text| {
1474                    output.push_str(text);
1475                    Ok(())
1476                })
1477                .expect("second delta");
1478            redactor
1479                .finish(|text| {
1480                    output.push_str(text);
1481                    Ok(())
1482                })
1483                .expect("finish");
1484            assert!(!output.contains(secret));
1485            assert!(output.len() <= secret.len());
1486        }
1487    }
1488
1489    #[test]
1490    fn malformed_tool_arguments_use_a_safe_copy() {
1491        let secret = "provider-secret";
1492        let escaped = secret
1493            .chars()
1494            .map(|character| format!(r#"\u{:04x}"#, character as u32))
1495            .collect::<String>();
1496        let arguments = format!(r#"{{"command":"{escaped}""#);
1497        let safe = redact_tool_arguments(&arguments, secret);
1498        assert_eq!(safe, "{}");
1499        serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1500        assert!(!safe.contains(secret));
1501        assert!(!safe.contains(&escaped));
1502        for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1503            assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1504        }
1505    }
1506
1507    #[test]
1508    fn structured_redaction_preserves_tool_and_result_schema_keys() {
1509        let secret = "provider-secret";
1510        let value = serde_json::json!({
1511            "command": "printf provider-secret",
1512            "stdout": "provider-secret",
1513            "stderr": "ordinary",
1514            "exit_code": 0,
1515            "timed_out": false,
1516            "stdout_truncated": false,
1517            "stderr_truncated": false,
1518            "unknown-provider-secret": "provider-secret"
1519        });
1520        let redacted = redact_json_value(value, secret);
1521        for key in [
1522            "command",
1523            "stdout",
1524            "stderr",
1525            "exit_code",
1526            "timed_out",
1527            "stdout_truncated",
1528            "stderr_truncated",
1529        ] {
1530            assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1531        }
1532        let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1533        assert!(!encoded.contains(secret));
1534        assert!(redacted.get("unknown-provider-secret").is_none());
1535    }
1536
1537    #[test]
1538    fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1539        let value = serde_json::json!({
1540            "exit_code": 0,
1541            "timed_out": false,
1542            "stdout_truncated": true,
1543            "error": null,
1544        });
1545        let redacted = redact_json_value(value, "0");
1546        assert!(redacted["exit_code"].is_number());
1547        assert!(redacted["timed_out"].is_boolean());
1548        assert!(redacted["stdout_truncated"].is_boolean());
1549        assert!(redacted["error"].is_null());
1550    }
1551
1552    #[test]
1553    fn reasoning_details_are_recursively_redacted_before_persistence() {
1554        let details = vec![serde_json::json!({
1555            "type": "reasoning.text",
1556            "text": "provider-secret",
1557            "nested": [{"value": "provider-secret"}],
1558            "provider-secret": "provider-secret"
1559        })];
1560        let redacted = redact_reasoning_details(&details, "provider-secret")
1561            .expect("non-empty reasoning details");
1562        let redacted = Value::Array(redacted);
1563        let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1564        assert!(!encoded.contains("provider-secret"));
1565        assert_eq!(redacted[0]["type"], "reasoning.text");
1566        assert_eq!(redacted[0]["text"], "[REDACTED]");
1567        assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1568        assert!(redacted[0].get("provider-secret").is_none());
1569    }
1570
1571    #[test]
1572    fn malformed_input_error_does_not_echo_secret_bearing_input() {
1573        let error =
1574            parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1575                .expect_err("invalid input");
1576        assert!(!error.contains("provider-secret"));
1577    }
1578
1579    #[test]
1580    fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1581        let mut output = Vec::new();
1582        let error = parse_input_message("not json").expect_err("invalid input");
1583        let mut protocol = ProtocolWriter::new(&mut output);
1584        protocol.error(&error).expect("error event");
1585        assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1586        let _ = Cursor::new("");
1587    }
1588
1589    #[test]
1590    fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1591        let secret = "lucy";
1592        let mut diagnostics = Vec::new();
1593        write_diagnostic_safe_with_environment(
1594            &mut diagnostics,
1595            secret,
1596            None,
1597            vec![secret.to_owned()],
1598        );
1599        let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1600        assert!(!diagnostics.contains(secret));
1601    }
1602    #[test]
1603    fn attached_agents_keeps_only_agents_files_and_redacts_their_paths() {
1604        let sources = vec![
1605            InstructionSource {
1606                path: std::path::PathBuf::from("/project/AGENTS.md"),
1607                contents: "agents".to_owned(),
1608            },
1609            InstructionSource {
1610                path: std::path::PathBuf::from("/project/CLAUDE.md"),
1611                contents: "claude".to_owned(),
1612            },
1613            InstructionSource {
1614                path: std::path::PathBuf::from("/private-secret/AGENTS.md"),
1615                contents: "agents".to_owned(),
1616            },
1617        ];
1618
1619        assert_eq!(
1620            attached_agents(sources, "secret"),
1621            vec!["/project/AGENTS.md", "/private-!/AGENTS.md"]
1622        );
1623    }
1624
1625    #[test]
1626    fn expands_slash_prefixed_skill_names_and_keeps_ordinary_messages() {
1627        let skill = SkillEntry {
1628            name: "release-notes".to_owned(),
1629            description: "Writes release notes".to_owned(),
1630            path: std::path::PathBuf::from("/skills/release-notes/SKILL.md"),
1631            contents: "# Release notes\nUse the template.".to_owned(),
1632            model_invocable: true,
1633        };
1634        let expanded = expand_skill_invocation("/release-notes v1.2", std::slice::from_ref(&skill))
1635            .expect("skill command");
1636        assert!(expanded.text.contains("# Release notes"));
1637        assert!(expanded.text.contains("User: v1.2"));
1638        assert_eq!(expanded.attached_skill.as_deref(), Some("release-notes"));
1639        let ordinary = expand_skill_invocation("ordinary message", &[]).expect("ordinary message");
1640        assert_eq!(ordinary.text, "ordinary message");
1641        assert_eq!(ordinary.attached_skill, None);
1642        assert_eq!(
1643            expand_skill_invocation("/missing", &[]).unwrap_err(),
1644            "unknown skill: missing"
1645        );
1646        assert_eq!(
1647            expand_skill_invocation("/skill:release-notes", &[skill]).unwrap_err(),
1648            "unknown skill: skill:release-notes"
1649        );
1650    }
1651}