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 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 pub(crate) context_window: Option<usize>,
386 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
890fn 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
905fn escape_xml_attribute(text: &str) -> String {
908 text.replace('&', "&")
909 .replace('<', "<")
910 .replace('>', ">")
911 .replace('\"', """)
912 .replace('\'', "'")
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#[derive(Debug)]
934struct ExpandedSkillInvocation {
935 text: String,
936 attached_skill: Option<String>,
937}
938
939fn 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 "{}".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}