1pub mod input;
17pub mod json;
18pub mod text;
19
20pub use input::{
21 PrintInputError, ProcessFileOptions, ProcessedFiles, PromptSource, build_initial_message,
22 process_file_arguments, read_piped_stdin,
23};
24pub use json::{render_json, render_json_event, render_json_events, render_json_header};
25pub use text::{TextOutcome, TextRenderer, render_text};
26
27use std::io;
28use std::sync::{Arc, Mutex};
29
30use futures::Stream;
31use pi_ai::ImageContent;
32
33use crate::core::agent_session::AgentSessionEvent;
34use crate::core::output_guard::{ProductOutput, flush_raw_stdout, write_raw_stdout};
35use crate::core::sessions::SessionHeader;
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum PrintOutput {
40 Text,
42 Json,
44}
45
46impl PrintOutput {
47 #[must_use]
49 pub const fn is_json(self) -> bool {
50 matches!(self, Self::Json)
51 }
52}
53
54#[derive(Clone, Debug)]
56pub struct PrintModeOptions {
57 pub mode: PrintOutput,
59 pub messages: Vec<String>,
61 pub initial_message: Option<String>,
63 pub initial_images: Vec<ImageContent>,
65}
66
67impl PrintModeOptions {
68 #[must_use]
70 pub fn new(mode: PrintOutput) -> Self {
71 Self {
72 mode,
73 messages: Vec::new(),
74 initial_message: None,
75 initial_images: Vec::new(),
76 }
77 }
78}
79
80pub trait PrintSink {
88 fn write_stdout(&self, text: &str) -> impl Future<Output = io::Result<()>> + Send;
90 fn write_stderr(&self, text: &str) -> impl Future<Output = io::Result<()>> + Send;
92 fn flush(&self) -> impl Future<Output = io::Result<()>> + Send;
94}
95
96#[derive(Clone, Copy, Debug)]
102pub struct OutputGuardSink;
103
104impl PrintSink for OutputGuardSink {
105 async fn write_stdout(&self, text: &str) -> io::Result<()> {
106 write_raw_stdout(text).await.map_err(io::Error::other)
107 }
108
109 async fn write_stderr(&self, text: &str) -> io::Result<()> {
110 ProductOutput::write(text);
111 Ok(())
112 }
113
114 async fn flush(&self) -> io::Result<()> {
115 flush_raw_stdout().await.map_err(io::Error::other)
116 }
117}
118
119#[derive(Clone, Default)]
124pub struct BufferSink {
125 stdout: Arc<Mutex<Vec<u8>>>,
126 stderr: Arc<Mutex<Vec<u8>>>,
127}
128
129impl BufferSink {
130 #[must_use]
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 #[must_use]
138 pub fn stdout_string(&self) -> String {
139 String::from_utf8_lossy(&lock_buffer(&self.stdout)).into_owned()
140 }
141
142 #[must_use]
144 pub fn stderr_string(&self) -> String {
145 String::from_utf8_lossy(&lock_buffer(&self.stderr)).into_owned()
146 }
147}
148
149fn lock_buffer(buf: &Mutex<Vec<u8>>) -> std::sync::MutexGuard<'_, Vec<u8>> {
152 buf.lock()
153 .unwrap_or_else(std::sync::PoisonError::into_inner)
154}
155
156impl PrintSink for BufferSink {
157 async fn write_stdout(&self, text: &str) -> io::Result<()> {
158 lock_buffer(&self.stdout).extend_from_slice(text.as_bytes());
159 Ok(())
160 }
161
162 async fn write_stderr(&self, text: &str) -> io::Result<()> {
163 lock_buffer(&self.stderr).extend_from_slice(text.as_bytes());
164 Ok(())
165 }
166
167 async fn flush(&self) -> io::Result<()> {
168 Ok(())
169 }
170}
171
172pub async fn run_print_mode<S, F, Fut, C, K>(
187 options: &PrintModeOptions,
188 header: Option<&SessionHeader>,
189 events: S,
190 drive_prompts: F,
191 finish_events: C,
192 sink: &K,
193) -> io::Result<i32>
194where
195 S: Stream<Item = AgentSessionEvent> + Send + Unpin,
196 F: FnOnce() -> Fut,
197 Fut: Future<Output = io::Result<()>>,
198 C: FnOnce(),
199 K: PrintSink,
200{
201 if options.mode.is_json() {
202 json::render_json_header(header, sink).await?;
203 }
204
205 let render = async {
206 match options.mode {
207 PrintOutput::Text => text::render_text(events, sink).await,
208 PrintOutput::Json => {
209 json::render_json_events(events, sink).await?;
210 Ok(0)
211 }
212 }
213 };
214 tokio::pin!(render);
215 let prompts = drive_prompts();
216 tokio::pin!(prompts);
217 let mut finish_events = Some(finish_events);
218
219 let (render_result, prompt_result) = tokio::select! {
220 prompt_result = &mut prompts => {
221 if let Some(finish) = finish_events.take() {
222 finish();
223 }
224 (render.await, prompt_result)
225 }
226 render_result = &mut render => {
227 let prompt_result = prompts.await;
228 if let Some(finish) = finish_events.take() {
229 finish();
230 }
231 (render_result, prompt_result)
232 }
233 };
234 prompt_result?;
235 let exit_code = render_result?;
236
237 sink.flush().await?;
238 Ok(exit_code)
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use futures::stream;
245 use pi_agent::AgentMessage;
246 use pi_ai::{AssistantContent, AssistantMessage, Message, StopReason, TextContent};
247 use std::sync::atomic::{AtomicBool, Ordering};
248
249 type TestResult = Result<(), Box<dyn std::error::Error>>;
250
251 fn assistant(text: &str, reason: StopReason) -> AgentMessage {
252 let mut msg = AssistantMessage::new("api", "provider", "model", 2);
253 if !text.is_empty() {
254 msg.content
255 .push(AssistantContent::Text(TextContent::new(text)));
256 }
257 msg.stop_reason = reason;
258 AgentMessage::Llm(Box::new(Message::Assistant(msg)))
259 }
260
261 #[tokio::test]
262 async fn run_print_mode_text_drives_prompts_and_renders() -> TestResult {
263 let final_msg = assistant("answer", StopReason::Stop);
264 let events = vec![AgentSessionEvent::AgentEnd {
265 messages: vec![final_msg],
266 will_retry: false,
267 }];
268 let options = PrintModeOptions::new(PrintOutput::Text);
269 let sink = BufferSink::default();
270
271 let prompt_called = Arc::new(AtomicBool::new(false));
272 let flag = Arc::clone(&prompt_called);
273 let code = run_print_mode(
274 &options,
275 None,
276 stream::iter(events),
277 move || {
278 let flag = Arc::clone(&flag);
279 async move {
280 flag.store(true, Ordering::SeqCst);
281 Ok(())
282 }
283 },
284 || {},
285 &sink,
286 )
287 .await?;
288
289 assert_eq!(code, 0);
290 assert_eq!(sink.stdout_string(), "answer\n");
291 assert!(prompt_called.load(Ordering::SeqCst));
292 Ok(())
293 }
294
295 #[tokio::test]
296 async fn run_print_mode_json_writes_header_events_exit_zero() -> TestResult {
297 let header = SessionHeader::new("sid", "2024-01-01T00:00:00.000Z", "/cwd", None);
298 let events = vec![
299 AgentSessionEvent::AgentStart,
300 AgentSessionEvent::AgentSettled,
301 ];
302 let options = PrintModeOptions::new(PrintOutput::Json);
303 let sink = BufferSink::default();
304
305 let code = run_print_mode(
306 &options,
307 Some(&header),
308 stream::iter(events),
309 || async { Ok(()) },
310 || {},
311 &sink,
312 )
313 .await?;
314
315 assert_eq!(code, 0);
316 let stdout = sink.stdout_string();
317 let lines: Vec<&str> = stdout.lines().collect();
318 assert_eq!(lines.len(), 3);
319 assert!(lines[0].contains("\"type\":\"session\""));
320 assert!(lines[1].contains("\"agent_start\""));
321 assert!(lines[2].contains("\"agent_settled\""));
322 Ok(())
323 }
324
325 #[tokio::test]
326 async fn run_print_mode_text_error_exit_one() -> TestResult {
327 let mut msg = AssistantMessage::new("api", "provider", "model", 2);
328 msg.stop_reason = StopReason::Error;
329 msg.error_message = Some("boom".into());
330 let events = vec![AgentSessionEvent::AgentEnd {
331 messages: vec![AgentMessage::Llm(Box::new(Message::Assistant(msg)))],
332 will_retry: false,
333 }];
334 let options = PrintModeOptions::new(PrintOutput::Text);
335 let sink = BufferSink::default();
336
337 let code = run_print_mode(
338 &options,
339 None,
340 stream::iter(events),
341 || async { Ok(()) },
342 || {},
343 &sink,
344 )
345 .await?;
346
347 assert_eq!(code, 1);
348 assert_eq!(sink.stderr_string(), "boom\n");
349 assert!(sink.stdout_string().is_empty());
350 Ok(())
351 }
352
353 #[tokio::test]
354 async fn prompt_failure_before_events_closes_renderer_and_returns_error() -> TestResult {
355 let options = PrintModeOptions::new(PrintOutput::Text);
356 let sink = BufferSink::default();
357 let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
358 let events = Box::pin(stream::unfold(event_rx, |mut receiver| async move {
359 receiver.recv().await.map(|event| (event, receiver))
360 }));
361
362 let result = tokio::time::timeout(
363 std::time::Duration::from_millis(100),
364 run_print_mode(
365 &options,
366 None,
367 events,
368 || async { Err(io::Error::other("preflight auth failed")) },
369 move || drop(event_tx),
370 &sink,
371 ),
372 )
373 .await;
374
375 let error = match result {
376 Ok(Err(error)) => error,
377 Ok(Ok(exit_code)) => {
378 return Err(format!(
379 "prompt failure must be preserved, but print mode returned exit code {exit_code}"
380 )
381 .into());
382 }
383 Err(error) => {
384 return Err(
385 format!("print mode must not hang after prompt setup fails: {error}").into(),
386 );
387 }
388 };
389 assert!(error.to_string().contains("preflight auth failed"));
390 Ok(())
391 }
392
393 #[tokio::test]
394 async fn buffer_sink_appends_in_order() -> TestResult {
395 let sink = BufferSink::default();
396 sink.write_stdout("a").await?;
397 sink.write_stdout("b").await?;
398 sink.write_stderr("e").await?;
399 assert_eq!(sink.stdout_string(), "ab");
400 assert_eq!(sink.stderr_string(), "e");
401 Ok(())
402 }
403}