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