I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

1 //! Headless programmatic mode: `sigit run "<prompt>"` runs one prompt and exits.
2 //!
3 //! This is the entry point for CI, scripts, cron, and the Cloud Agent sandbox
4 //! runner: no client, no TTY, just plain stdio. Assistant text streams to
5 //! stdout as it is generated (`--quiet` restricts stdout to the final message);
6 //! logs and tool progress go to stderr.
7 //!
8 //! Cross-platform by design — unlike the ratatui TUI this is NOT gated on
9 //! `#[cfg(unix)]`, so Windows gets it too.
10 //!
11 //! Permission model: nobody is around to answer `ask`, so `ask` collapses to a
12 //! denial telling the model the tool was not pre-approved (mentioning
13 //! `--allow-tool`). `--allow-tool <name>` pre-grants a tool for the run's
14 //! session; `--deny-tool <name>` blocks a tool even if settings would allow it.
15 //! `SIGIT_PERMISSIONS=allow` remains the blunt instrument.
16 //!
17 //! Exit codes: 0 — turn completed; 1 — inference/tool-loop or backend
18 //! resolution error; 2 — bad invocation (handled by the caller in `main`).
19
20 use std::collections::HashSet;
21 use std::io::Write;
22 use std::path::PathBuf;
23 use std::sync::Arc;
24
25 use crate::backend::{
26 self, InferenceBackend, OpenAiBackend, TokenChunk, ToolResult as BackendToolResult, TurnResult,
27 };
28 use crate::{permissions, provider, session_store, settings, tools};
29
30 pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31 [--output text|jsonl] [--quiet] [--resume <session-id>] \
32 [--allow-tool <name>]... [--deny-tool <name>]... \
33 [--max-tool-rounds <n>] [--model <tier>]\n \
34 sigit -p \"<prompt>\" [same options]";
35
36 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37 pub enum OutputFormat {
38 #[default]
39 Text,
40 Jsonl,
41 }
42
43 /// Parsed `sigit run` or legacy `sigit -p` invocation.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub struct HeadlessConfig {
46 pub prompt: String,
47 /// Durable session id. A fresh UUID is generated unless `--resume` names
48 /// an existing session.
49 pub session_id: String,
50 /// Restore the saved conversation before sending `prompt`.
51 pub resume: bool,
52 /// Working directory to enter before anything loads (instruction files and
53 /// project-local MCP config then resolve from it).
54 pub cwd: Option<PathBuf>,
55 /// Extra project roots beyond `cwd`, the headless counterpart of a
56 /// multi-root project in the editor (see `workspace.rs`).
57 pub add_dirs: Vec<PathBuf>,
58 /// Print only the final assistant message to stdout (no streaming).
59 pub quiet: bool,
60 /// Machine-readable event output for CI and factory clients.
61 pub output: OutputFormat,
62 /// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
63 pub allow_tools: Vec<String>,
64 /// Tools blocked for the run, overriding even settings-level allow.
65 pub deny_tools: Vec<String>,
66 /// How many rounds of tool calls the run may make before the model is
67 /// asked for its final message. `--max-tool-rounds`, else
68 /// `SIGIT_MAX_TOOL_ROUNDS`, else the built-in cap.
69 pub max_tool_rounds: usize,
70 /// siGit Code Cloud tier named by `--model`. When set it wins over the
71 /// provider override and the local-inference setting.
72 pub cloud_tier: Option<&'static str>,
73 }
74
75 /// Upper bound for `--max-tool-rounds` / `SIGIT_MAX_TOOL_ROUNDS`.
76 pub const MAX_TOOL_ROUNDS_LIMIT: usize = 500;
77
78 /// Environment override for the tool-round cap of a headless run or an ACP
79 /// prompt turn.
80 pub const MAX_TOOL_ROUNDS_ENV: &str = "SIGIT_MAX_TOOL_ROUNDS";
81
82 fn parse_tool_rounds(value: &str, source: &str) -> Result<usize, String> {
83 match value.trim().parse::<usize>() {
84 Ok(rounds) if (1..=MAX_TOOL_ROUNDS_LIMIT).contains(&rounds) => Ok(rounds),
85 _ => Err(format!(
86 "{source} must be a whole number from 1 to {MAX_TOOL_ROUNDS_LIMIT}"
87 )),
88 }
89 }
90
91 /// The tool-round cap for a run: the flag wins, then the environment, then
92 /// the built-in cap. An empty environment value counts as unset.
93 fn resolve_max_tool_rounds(flag: Option<usize>, env: Option<&str>) -> Result<usize, String> {
94 if let Some(rounds) = flag {
95 return Ok(rounds);
96 }
97 match env.map(str::trim).filter(|value| !value.is_empty()) {
98 Some(value) => parse_tool_rounds(value, MAX_TOOL_ROUNDS_ENV),
99 None => Ok(crate::MAX_TOOL_ROUNDS),
100 }
101 }
102
103 /// The tool-round cap for a surface with no flag of its own (an ACP prompt
104 /// turn). A value that does not parse is logged and ignored: the editor
105 /// launched the agent, so there is nobody to show a startup error to.
106 pub fn max_tool_rounds_from_env() -> usize {
107 resolve_max_tool_rounds(None, std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref())
108 .unwrap_or_else(|error| {
109 log::warn!(
110 "{error}; using the built-in cap of {}",
111 crate::MAX_TOOL_ROUNDS
112 );
113 crate::MAX_TOOL_ROUNDS
114 })
115 }
116
117 /// What the model is told when a run would otherwise end with no final
118 /// message. `cap_reached` says the tool-round cap is why.
119 fn wrap_up_prompt(cap_reached: bool, max_tool_rounds: usize) -> String {
120 let lead = if cap_reached {
121 format!("You have used all {max_tool_rounds} tool rounds available for this run. ")
122 } else {
123 String::new()
124 };
125 format!(
126 "{lead}Write your final message now, without calling any tools: what you did, \
127 what you found, and anything left unfinished."
128 )
129 }
130
131 /// Parse the process arguments (without argv[0]) for headless mode.
132 ///
133 /// Returns `Ok(None)` when neither `run` nor `-p`/`--prompt` is present — the
134 /// invocation is not headless and falls through to the TTY/ACP dispatch. Once
135 /// headless mode is selected,
136 /// every remaining argument must be a recognized flag (position-insensitive);
137 /// anything else is a usage error the caller reports on stderr with exit 2.
138 pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
139 let run_command = args.first().is_some_and(|arg| arg == "run");
140 if !run_command && !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
141 return Ok(None);
142 }
143
144 let mut prompt: Option<String> = None;
145 let mut cwd: Option<PathBuf> = None;
146 let mut add_dirs: Vec<PathBuf> = Vec::new();
147 let mut quiet = false;
148 let mut output = OutputFormat::Text;
149 let mut resume_session: Option<String> = None;
150 let mut allow_tools: Vec<String> = Vec::new();
151 let mut deny_tools: Vec<String> = Vec::new();
152 let mut max_tool_rounds: Option<usize> = None;
153 let mut cloud_tier: Option<&'static str> = None;
154
155 let mut iter = args.iter().skip(usize::from(run_command));
156 while let Some(arg) = iter.next() {
157 match arg.as_str() {
158 "-p" | "--prompt" => {
159 let value = iter
160 .next()
161 .ok_or_else(|| format!("{arg} requires a prompt argument"))?;
162 if prompt.is_some() {
163 return Err("the prompt was given more than once".to_string());
164 }
165 prompt = Some(value.clone());
166 }
167 "--cwd" => {
168 let value = iter
169 .next()
170 .ok_or_else(|| "--cwd requires a directory argument".to_string())?;
171 cwd = Some(PathBuf::from(value));
172 }
173 "--add-dir" => {
174 let value = iter
175 .next()
176 .ok_or_else(|| "--add-dir requires a directory argument".to_string())?;
177 add_dirs.push(PathBuf::from(value));
178 }
179 "--quiet" => quiet = true,
180 "--output" => {
181 let value = iter
182 .next()
183 .ok_or_else(|| "--output requires text or jsonl".to_string())?;
184 output = match value.as_str() {
185 "text" => OutputFormat::Text,
186 "jsonl" => OutputFormat::Jsonl,
187 _ => return Err("--output must be text or jsonl".to_string()),
188 };
189 }
190 "--resume" => {
191 let value = iter
192 .next()
193 .ok_or_else(|| "--resume requires a session id".to_string())?;
194 if resume_session.is_some() {
195 return Err("--resume was given more than once".to_string());
196 }
197 validate_session_id(value)?;
198 resume_session = Some(value.clone());
199 }
200 "--allow-tool" => {
201 let value = iter
202 .next()
203 .ok_or_else(|| "--allow-tool requires a tool name".to_string())?;
204 allow_tools.push(value.clone());
205 }
206 "--deny-tool" => {
207 let value = iter
208 .next()
209 .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
210 deny_tools.push(value.clone());
211 }
212 "--max-tool-rounds" => {
213 let value = iter
214 .next()
215 .ok_or_else(|| "--max-tool-rounds requires a number".to_string())?;
216 max_tool_rounds = Some(parse_tool_rounds(value, "--max-tool-rounds")?);
217 }
218 "--model" => {
219 let value = iter
220 .next()
221 .ok_or_else(|| "--model requires a cloud tier".to_string())?;
222 if cloud_tier.is_some() {
223 return Err("--model was given more than once".to_string());
224 }
225 let tier = provider::cloud_tier_named(value).ok_or_else(|| {
226 format!(
227 "unknown cloud tier: {value} (expected one of: {})",
228 provider::CLOUD_TIERS.join(", ")
229 )
230 })?;
231 cloud_tier = Some(tier);
232 }
233 // A mistyped flag must not become the prompt. A prompt that
234 // really starts with '-' can still go through -p.
235 other if other.starts_with('-') => return Err(format!("unknown argument: {other}")),
236 other if run_command && prompt.is_none() => prompt = Some(other.to_string()),
237 other if run_command => {
238 return Err(format!(
239 "unexpected extra argument: {other} (quote the prompt as one argument)"
240 ));
241 }
242 other => return Err(format!("unknown argument: {other}")),
243 }
244 }
245
246 // Reaching this without a prompt means `run` had no positional prompt, or
247 // a legacy prompt flag was consumed as another flag's value.
248 let prompt = prompt.ok_or_else(|| {
249 if run_command {
250 "missing prompt".to_string()
251 } else {
252 "missing -p/--prompt".to_string()
253 }
254 })?;
255 if prompt.trim().is_empty() {
256 return Err("the prompt must not be empty".to_string());
257 }
258 if quiet && output == OutputFormat::Jsonl {
259 return Err("--quiet cannot be combined with --output jsonl".to_string());
260 }
261
262 let resume = resume_session.is_some();
263 let session_id = resume_session.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
264 let max_tool_rounds = resolve_max_tool_rounds(
265 max_tool_rounds,
266 std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref(),
267 )?;
268
269 Ok(Some(HeadlessConfig {
270 prompt,
271 session_id,
272 resume,
273 cwd,
274 add_dirs,
275 quiet,
276 output,
277 allow_tools,
278 deny_tools,
279 max_tool_rounds,
280 cloud_tier,
281 }))
282 }
283
284 fn validate_session_id(session_id: &str) -> Result<(), String> {
285 if session_id.is_empty()
286 || !session_id.chars().all(|character| {
287 character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
288 })
289 {
290 return Err(
291 "session ids may contain only ASCII letters, numbers, '.', '_', and '-'".to_string(),
292 );
293 }
294 Ok(())
295 }
296
297 /// Denial fed to the model when a tool at `ask` level fires in a headless run.
298 fn not_preapproved_denial(tool_name: &str) -> String {
299 format!(
300 "`{tool_name}` was not executed: this is a non-interactive headless run and \
301 nobody can answer a permission prompt, so tools at the `ask` level are denied \
302 unless pre-approved. The user can re-run with `--allow-tool {tool_name}` to \
303 approve it (or set SIGIT_PERMISSIONS=allow). Do not retry the same call; \
304 continue without this tool or report what remains to be done."
305 )
306 }
307
308 /// Denial fed to the model when a tool was blocked with `--deny-tool`.
309 fn deny_flag_denial(tool_name: &str) -> String {
310 format!(
311 "`{tool_name}` is blocked for this headless run (--deny-tool). Do not retry it; \
312 continue without this tool or report what remains to be done."
313 )
314 }
315
316 /// Run one headless prompt to completion. Returns the process exit code.
317 ///
318 /// The caller (`main`) has already applied `--cwd` and `--add-dir`, initialized
319 /// logging to stderr, set up the model cache, and run MCP discovery.
320 pub async fn run(config: HeadlessConfig) -> i32 {
321 // Fresh permission state for the run, then apply the flag grants.
322 permissions::reset_session(&config.session_id);
323 tools::set_active_session(Some(&config.session_id));
324 for tool in &config.allow_tools {
325 // No call arguments at grant time: the empty string records the bare
326 // tool name, granting the whole tool — the --allow-tool contract.
327 permissions::grant_for_session(&config.session_id, tool, "");
328 }
329 let denied: HashSet<&str> = config.deny_tools.iter().map(String::as_str).collect();
330
331 // Backend resolution mirrors the ACP server: the explicit provider override
332 // first, else the signed-in cloud tier when local inference is off (what
333 // `apply_startup_inference_mode` does at every ACP session entry). There is
334 // never an implicit on-device load — a fresh process has no model in memory
335 // and headless mode must not silently download gigabytes. `--model` is the
336 // exception to that order: a tier named on the command line wins over the
337 // override and the setting, the way a flag wins over the environment.
338 let provider_cfg = match config.cloud_tier {
339 Some(tier) => provider::cloud_tier_provider(tier),
340 None => provider::active_provider().or_else(|| {
341 if settings::local_inference_enabled() {
342 None
343 } else {
344 provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
345 }
346 }),
347 };
348 let Some(cfg) = provider_cfg else {
349 let message = match config.cloud_tier {
350 Some(tier) => format!(
351 "--model {tier} needs a siGit Code Cloud session. Sign in with `sigit login`."
352 ),
353 None => "headless mode needs a remote inference provider — running on-device would \
354 require loading (and possibly downloading) a local model, which headless \
355 mode never does implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY (or \
356 configure providers.toml), or sign in with `sigit login` and turn local \
357 inference off."
358 .to_string(),
359 };
360 emit_error(&config, &message);
361 return 1;
362 };
363
364 log::info!(
365 "headless: using {} (model {}) at {}",
366 cfg.display_name,
367 cfg.model,
368 cfg.base_url
369 );
370 crate::register_subagent_factory_for(&cfg);
371
372 // Same always-on project context the other surfaces inject: cwd guidance
373 // plus AGENTS.md / CLAUDE.md instruction files.
374 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
375 let mut system_prompt = crate::system_prompt_for_model(true);
376 system_prompt.push_str("\n\n");
377 system_prompt.push_str(&crate::session_context_message(
378 &cwd,
379 &crate::workspace::additional_roots(),
380 ));
381
382 let backend: Arc<dyn InferenceBackend> = Arc::new(
383 OpenAiBackend::new(
384 cfg.base_url,
385 cfg.api_key,
386 cfg.model.clone(),
387 Some(system_prompt),
388 )
389 .with_session_token(cfg.session_token),
390 );
391
392 if config.resume {
393 let Some(history) = session_store::load(&config.session_id) else {
394 emit_error(&config, "saved session not found");
395 return 1;
396 };
397 backend::adopt_carryover(backend.as_ref(), backend::carryover_history(history)).await;
398 }
399
400 emit_event(
401 &config,
402 serde_json::json!({
403 "type": "session",
404 "session_id": config.session_id,
405 "resumed": config.resume,
406 }),
407 );
408 if config.output == OutputFormat::Text {
409 eprintln!("Session: {}", config.session_id);
410 }
411
412 let outcome = run_prompt(backend.as_ref(), &config, &denied).await;
413
414 // Persist the conversation like the other surfaces, so a follow-up feature
415 // can resume it. Saved even on error: a partial transcript beats none.
416 let snapshot = backend.history_snapshot().await;
417 if let Err(error) = session_store::save(&config.session_id, &snapshot) {
418 log::warn!("headless: session save failed: {error}");
419 }
420 session_store::save_meta(
421 &config.session_id,
422 &cwd,
423 &crate::workspace::additional_roots(),
424 Some(&cfg.model),
425 );
426
427 match outcome {
428 Ok(()) => 0,
429 Err(error) => {
430 emit_error(&config, &error.to_string());
431 1
432 }
433 }
434 }
435
436 fn emit_event(config: &HeadlessConfig, event: serde_json::Value) {
437 if config.output == OutputFormat::Jsonl {
438 println!("{event}");
439 let _ = std::io::stdout().flush();
440 }
441 }
442
443 fn emit_error(config: &HeadlessConfig, message: &str) {
444 if config.output == OutputFormat::Jsonl {
445 emit_event(
446 config,
447 serde_json::json!({
448 "type": "error",
449 "session_id": config.session_id,
450 "message": message,
451 }),
452 );
453 } else {
454 eprintln!("sigit: {message}");
455 }
456 }
457
458 /// The turn loop: send the prompt, execute tool calls under the permission
459 /// policy, feed results back, repeat up to `config.max_tool_rounds` — the same
460 /// shape as the ACP `handle_prompt`, minus the ACP notifications.
461 async fn run_prompt(
462 backend: &dyn InferenceBackend,
463 config: &HeadlessConfig,
464 denied: &HashSet<&str>,
465 ) -> Result<(), backend::BackendError> {
466 let tools = crate::agent_tools_as_specs();
467
468 // Token sink: assistant text streams through this while a turn runs; the
469 // drain loop forwards the visible portion to stdout live. In quiet mode no
470 // sink is passed and only the final message is printed.
471 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
472 let sink_opt = if config.quiet { None } else { Some(&sink) };
473 let mut reply = crate::StreamedReply::default();
474
475 let mut result = drain_to_stdout(
476 backend.send_message_with_tools(&config.prompt, &tools, sink_opt),
477 &mut sink_rx,
478 &mut reply,
479 config,
480 )
481 .await?;
482
483 let mut round = 0;
484
485 while !result.tool_calls.is_empty() && round < config.max_tool_rounds {
486 round += 1;
487
488 // Auto-compaction: long tool runs grow history fast; fold it into a
489 // summary before the next round rather than blowing the window.
490 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
491 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
492 log::info!(
493 "headless: history ≈{estimate} tokens exceeds budget {} — compacting",
494 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
495 );
496 if let Err(error) = backend.compact_history(backend::COMPACT_KEEP_LAST).await {
497 log::warn!("headless: compaction failed: {error}");
498 }
499 }
500
501 let mut tool_results = Vec::new();
502
503 for tc in &result.tool_calls {
504 let args_preview: String = tc.arguments.chars().take(120).collect();
505 if config.output == OutputFormat::Jsonl {
506 emit_event(
507 config,
508 serde_json::json!({
509 "type": "tool_call",
510 "session_id": config.session_id,
511 "tool_call_id": tc.id,
512 "name": tc.name,
513 "arguments": tc.arguments,
514 }),
515 );
516 } else {
517 eprintln!("→ {}({args_preview})", tc.name);
518 }
519
520 // The headless deny set outranks everything, including a
521 // settings-level allow and a --allow-tool grant for the same name.
522 let output = if denied.contains(tc.name.as_str()) {
523 log::info!("headless: {} blocked by --deny-tool", tc.name);
524 deny_flag_denial(&tc.name)
525 } else {
526 match permissions::decision_for(&config.session_id, &tc.name, &tc.arguments) {
527 permissions::Decision::Allow => {
528 tools::execute_tool(&tc.name, &tc.arguments).await
529 }
530 permissions::Decision::Deny(reason) => {
531 log::info!("headless: {} denied by policy", tc.name);
532 reason
533 }
534 // Nobody can answer an interactive prompt here: ask
535 // collapses to a denial pointing at --allow-tool.
536 permissions::Decision::Ask => {
537 log::info!("headless: {} not pre-approved — denied", tc.name);
538 not_preapproved_denial(&tc.name)
539 }
540 }
541 };
542
543 emit_event(
544 config,
545 serde_json::json!({
546 "type": "tool_result",
547 "session_id": config.session_id,
548 "tool_call_id": tc.id,
549 "name": tc.name,
550 "content": output,
551 }),
552 );
553
554 tool_results.push(BackendToolResult {
555 tool_call_id: tc.id.clone(),
556 content: output,
557 });
558 }
559
560 let allow_tool_calls = round < config.max_tool_rounds;
561
562 // The round that reaches the cap is forced to answer in text. Say so,
563 // or the model spends its last reply narrating the step it can no
564 // longer take.
565 if !allow_tool_calls && let Some(last) = tool_results.last_mut() {
566 last.content
567 .push_str(&crate::headless_round_cap_note(config.max_tool_rounds));
568 }
569
570 // Whatever this round says starts a new paragraph rather than
571 // continuing the sentence the tool calls interrupted.
572 reply.interrupt();
573
574 result = drain_to_stdout(
575 backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
576 &mut sink_rx,
577 &mut reply,
578 config,
579 )
580 .await?;
581 }
582
583 // A run can reach this point with nothing to show: the last round was
584 // forced to answer in text and the model answered with nothing, most
585 // often because it was cut off at the tool-round cap mid-task. A caller
586 // reading stdout could not tell that from a run with nothing to report,
587 // so ask once, plainly, for the final message.
588 if crate::chat::strip_think_blocks(result.text.trim())
589 .1
590 .is_empty()
591 {
592 let cap_reached = round >= config.max_tool_rounds;
593 if cap_reached {
594 log::warn!(
595 "headless: reached the cap of {} tool round(s) without a final message",
596 config.max_tool_rounds
597 );
598 }
599 reply.interrupt();
600 match drain_to_stdout(
601 backend.send_message_with_tools(
602 &wrap_up_prompt(cap_reached, config.max_tool_rounds),
603 &[],
604 sink_opt,
605 ),
606 &mut sink_rx,
607 &mut reply,
608 config,
609 )
610 .await
611 {
612 Ok(wrap_up) => result = wrap_up,
613 Err(error) => log::warn!("headless: asking for a final message failed: {error}"),
614 }
615 }
616
617 // Final text: in quiet mode nothing streamed, so print the final assistant
618 // message now; otherwise print only what streaming did not already cover.
619 let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
620 let mut stdout = std::io::stdout();
621 if config.output == OutputFormat::Jsonl {
622 emit_event(
623 config,
624 serde_json::json!({
625 "type": "result",
626 "session_id": config.session_id,
627 "text": final_visible,
628 "tool_rounds": round,
629 }),
630 );
631 } else if config.quiet {
632 if !final_visible.is_empty() {
633 let _ = writeln!(stdout, "{final_visible}");
634 }
635 } else if reply.streamed_any {
636 // The reply is already on stdout; end the line for the shell.
637 let _ = writeln!(stdout);
638 } else if !final_visible.is_empty() {
639 let _ = writeln!(stdout, "{final_visible}");
640 }
641 let _ = stdout.flush();
642
643 log::info!("headless: prompt complete — {round} tool round(s)");
644 Ok(())
645 }
646
647 /// Run one inference turn while forwarding streamed tokens to stdout as they
648 /// arrive — the stdio counterpart of `SiGitAgent::drain_turn`.
649 async fn drain_to_stdout<F>(
650 fut: F,
651 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TokenChunk>,
652 reply: &mut crate::StreamedReply,
653 config: &HeadlessConfig,
654 ) -> Result<TurnResult, backend::BackendError>
655 where
656 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
657 {
658 tokio::pin!(fut);
659 let result = loop {
660 tokio::select! {
661 done = &mut fut => break done,
662 Some(chunk) = sink_rx.recv() => {
663 emit_token_chunk(chunk, reply, config);
664 }
665 }
666 };
667 // Flush tokens that landed between the last poll and the future resolving.
668 while let Ok(chunk) = sink_rx.try_recv() {
669 emit_token_chunk(chunk, reply, config);
670 }
671 result
672 }
673
674 /// Fold a streamed fragment into `reply` and print whatever it newly reveals —
675 /// the stdio counterpart of `SiGitAgent::emit_token_chunk`. Reasoning (from a
676 /// dedicated field or an inline `<think>` block) never joins the stdout answer
677 /// stream; under `--output jsonl` it surfaces as a separate `reasoning_delta`
678 /// event, and is dropped in plain mode.
679 fn emit_token_chunk(chunk: TokenChunk, reply: &mut crate::StreamedReply, config: &HeadlessConfig) {
680 match chunk {
681 TokenChunk::Reasoning(text) => emit_reasoning_delta(&text, config),
682 TokenChunk::Visible(piece) => {
683 let revealed = reply.push(&piece);
684 if let Some(reasoning) = revealed.reasoning {
685 emit_reasoning_delta(&reasoning, config);
686 }
687 if let Some(extra) = revealed.visible {
688 if config.output == OutputFormat::Jsonl {
689 emit_event(
690 config,
691 serde_json::json!({
692 "type": "assistant_delta",
693 "session_id": config.session_id,
694 "text": extra,
695 }),
696 );
697 } else {
698 print!("{extra}");
699 }
700 let _ = std::io::stdout().flush();
701 }
702 }
703 }
704 }
705
706 /// Emit model reasoning as a structured event under `--output jsonl`. In plain
707 /// mode reasoning is not part of the answer, so it is dropped.
708 fn emit_reasoning_delta(text: &str, config: &HeadlessConfig) {
709 if config.output == OutputFormat::Jsonl {
710 emit_event(
711 config,
712 serde_json::json!({
713 "type": "reasoning_delta",
714 "session_id": config.session_id,
715 "text": text,
716 }),
717 );
718 }
719 }
720
721 #[cfg(test)]
722 mod tests {
723 use super::*;
724
725 fn args(list: &[&str]) -> Vec<String> {
726 list.iter().map(|s| s.to_string()).collect()
727 }
728
729 #[test]
730 fn absent_prompt_flag_is_not_headless() {
731 assert_eq!(parse_args(&args(&[])), Ok(None));
732 assert_eq!(parse_args(&args(&["login"])), Ok(None));
733 // Flags alone don't trigger headless mode; only -p/--prompt does.
734 assert_eq!(parse_args(&args(&["--quiet"])), Ok(None));
735 }
736
737 #[test]
738 fn parses_minimal_invocation() {
739 let config = parse_args(&args(&["-p", "do the thing"])).unwrap().unwrap();
740 assert_eq!(config.prompt, "do the thing");
741 assert!(!config.session_id.is_empty());
742 assert!(!config.resume);
743 assert_eq!(config.cwd, None);
744 assert!(!config.quiet);
745 assert_eq!(config.output, OutputFormat::Text);
746 assert!(config.allow_tools.is_empty());
747 assert!(config.deny_tools.is_empty());
748 }
749
750 #[test]
751 fn run_subcommand_accepts_a_positional_prompt() {
752 let config = parse_args(&args(&["run", "do the thing"]))
753 .unwrap()
754 .unwrap();
755 assert_eq!(config.prompt, "do the thing");
756 assert!(!config.resume);
757 }
758
759 #[test]
760 fn long_form_prompt_flag_works() {
761 let config = parse_args(&args(&["--prompt", "hello"])).unwrap().unwrap();
762 assert_eq!(config.prompt, "hello");
763 }
764
765 #[test]
766 fn parses_all_flags_position_insensitively() {
767 let config = parse_args(&args(&[
768 "--quiet",
769 "--allow-tool",
770 "run_command",
771 "--cwd",
772 "/tmp/project",
773 "-p",
774 "build it",
775 "--deny-tool",
776 "delete_file",
777 "--output",
778 "text",
779 "--allow-tool",
780 "edit_file",
781 ]))
782 .unwrap()
783 .unwrap();
784 assert_eq!(config.prompt, "build it");
785 assert_eq!(config.cwd, Some(PathBuf::from("/tmp/project")));
786 assert!(config.quiet);
787 assert_eq!(config.output, OutputFormat::Text);
788 assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
789 assert_eq!(config.deny_tools, vec!["delete_file"]);
790 }
791
792 #[test]
793 fn resume_reuses_the_named_session() {
794 let config = parse_args(&args(&[
795 "run",
796 "continue",
797 "--resume",
798 "session-123",
799 "--output",
800 "jsonl",
801 ]))
802 .unwrap()
803 .unwrap();
804 assert_eq!(config.session_id, "session-123");
805 assert!(config.resume);
806 assert_eq!(config.output, OutputFormat::Jsonl);
807 }
808
809 #[test]
810 fn resume_rejects_unsafe_session_ids() {
811 let error = parse_args(&args(&["run", "continue", "--resume", "../escape"])).unwrap_err();
812 assert!(error.contains("session ids"), "{error}");
813 }
814
815 #[test]
816 fn max_tool_rounds_flag_is_parsed_and_bounded() {
817 let config = parse_args(&args(&["-p", "work", "--max-tool-rounds", "60"]))
818 .unwrap()
819 .unwrap();
820 assert_eq!(config.max_tool_rounds, 60);
821
822 for bad in ["0", "501", "-1", "many", ""] {
823 let error = parse_args(&args(&["-p", "work", "--max-tool-rounds", bad])).unwrap_err();
824 assert!(error.contains("--max-tool-rounds"), "{bad}: {error}");
825 }
826 assert!(parse_args(&args(&["-p", "work", "--max-tool-rounds"])).is_err());
827 }
828
829 #[test]
830 fn max_tool_rounds_resolves_flag_then_environment_then_default() {
831 assert_eq!(resolve_max_tool_rounds(Some(7), Some("60")), Ok(7));
832 assert_eq!(resolve_max_tool_rounds(None, Some(" 60 ")), Ok(60));
833 assert_eq!(
834 resolve_max_tool_rounds(None, None),
835 Ok(crate::MAX_TOOL_ROUNDS)
836 );
837 // An empty variable is unset, not an error.
838 assert_eq!(
839 resolve_max_tool_rounds(None, Some("")),
840 Ok(crate::MAX_TOOL_ROUNDS)
841 );
842 let error = resolve_max_tool_rounds(None, Some("lots")).unwrap_err();
843 assert!(error.contains(MAX_TOOL_ROUNDS_ENV), "{error}");
844 assert!(resolve_max_tool_rounds(None, Some("0")).is_err());
845 }
846
847 #[test]
848 fn model_flag_names_a_cloud_tier() {
849 let config = parse_args(&args(&["run", "go"])).unwrap().unwrap();
850 assert_eq!(config.cloud_tier, None);
851 let config = parse_args(&args(&["run", "go", "--model", "prism"]))
852 .unwrap()
853 .unwrap();
854 assert_eq!(config.cloud_tier, Some("prism"));
855 // The wire id works too, so a model id copied from logs is accepted.
856 let config = parse_args(&args(&["-p", "go", "--model", "onde-zenith"]))
857 .unwrap()
858 .unwrap();
859 assert_eq!(config.cloud_tier, Some("zenith"));
860 }
861
862 #[test]
863 fn model_flag_rejects_unknown_and_repeated_tiers() {
864 let error = parse_args(&args(&["run", "go", "--model", "gpt-4o"])).unwrap_err();
865 assert!(error.starts_with("unknown cloud tier: gpt-4o"), "{error}");
866 assert!(error.contains("prism"), "{error}");
867 assert_eq!(
868 parse_args(&args(&["run", "go", "--model", "apex", "--model", "flare"])),
869 Err("--model was given more than once".to_string())
870 );
871 }
872
873 #[test]
874 fn wrap_up_prompt_names_the_cap_only_when_it_was_reached() {
875 let capped = wrap_up_prompt(true, 24);
876 assert!(capped.contains("all 24 tool rounds"), "{capped}");
877 assert!(capped.contains("without calling any tools"));
878
879 let plain = wrap_up_prompt(false, 24);
880 assert!(!plain.contains("tool rounds"), "{plain}");
881 assert!(plain.starts_with("Write your final message now"));
882 }
883
884 #[test]
885 fn quiet_and_jsonl_are_mutually_exclusive() {
886 let error =
887 parse_args(&args(&["run", "work", "--quiet", "--output", "jsonl"])).unwrap_err();
888 assert!(error.contains("cannot be combined"), "{error}");
889 }
890
891 #[test]
892 fn add_dir_is_repeatable_and_keeps_its_order() {
893 let config = parse_args(&args(&[
894 "-p",
895 "look around",
896 "--add-dir",
897 "/tmp/lib",
898 "--add-dir",
899 "/tmp/docs",
900 ]))
901 .unwrap()
902 .unwrap();
903 assert_eq!(
904 config.add_dirs,
905 vec![PathBuf::from("/tmp/lib"), PathBuf::from("/tmp/docs")]
906 );
907 }
908
909 #[test]
910 fn add_dir_without_a_value_is_a_usage_error() {
911 assert!(parse_args(&args(&["-p", "x", "--add-dir"])).is_err());
912 }
913
914 #[test]
915 fn prompt_value_may_look_like_a_flag() {
916 // -p consumes the next argument verbatim.
917 let config = parse_args(&args(&["-p", "--quiet"])).unwrap().unwrap();
918 assert_eq!(config.prompt, "--quiet");
919 assert!(!config.quiet);
920 }
921
922 #[test]
923 fn unknown_flag_is_a_usage_error() {
924 let error = parse_args(&args(&["-p", "x", "--frobnicate"])).unwrap_err();
925 assert!(error.contains("--frobnicate"), "{error}");
926 }
927
928 #[test]
929 fn missing_values_are_usage_errors() {
930 assert!(parse_args(&args(&["-p"])).is_err());
931 assert!(parse_args(&args(&["-p", "x", "--cwd"])).is_err());
932 assert!(parse_args(&args(&["-p", "x", "--allow-tool"])).is_err());
933 assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
934 assert!(parse_args(&args(&["run", "x", "--output"])).is_err());
935 assert!(parse_args(&args(&["run", "x", "--resume"])).is_err());
936 assert!(parse_args(&args(&["run", "x", "--model"])).is_err());
937 }
938
939 #[test]
940 fn duplicate_prompt_is_a_usage_error() {
941 assert!(parse_args(&args(&["-p", "a", "--prompt", "b"])).is_err());
942 }
943
944 #[test]
945 fn run_rejects_a_second_prompt_from_either_source() {
946 let error = parse_args(&args(&["run", "a", "b"])).unwrap_err();
947 assert!(error.contains("unexpected extra argument: b"), "{error}");
948 let error = parse_args(&args(&["run", "a", "-p", "b"])).unwrap_err();
949 assert!(error.contains("more than once"), "{error}");
950 let error = parse_args(&args(&["run", "-p", "a", "b"])).unwrap_err();
951 assert!(error.contains("unexpected extra argument: b"), "{error}");
952 }
953
954 #[test]
955 fn run_does_not_take_a_mistyped_flag_as_the_prompt() {
956 let error = parse_args(&args(&["run", "--quite", "fix it"])).unwrap_err();
957 assert!(error.contains("unknown argument: --quite"), "{error}");
958 }
959
960 #[test]
961 fn bare_run_reports_a_missing_prompt() {
962 assert_eq!(
963 parse_args(&args(&["run"])),
964 Err("missing prompt".to_string())
965 );
966 }
967
968 #[test]
969 fn empty_prompt_is_a_usage_error() {
970 assert!(parse_args(&args(&["-p", " "])).is_err());
971 }
972 }