Skip to main content

rpi_cli/
modes.rs

1//! Output modes. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/modes/{print-mode,json-event,rpc-mode}.ts` — the
3//! three run shapes a harness-backed CLI needs:
4//!
5//! - [`print`] — single-shot: send the prompt(s), print the final assistant
6//!   text (or the error) to stdout, exit. Mirrors TS `runPrintMode` (text mode).
7//! - [`json`] — single-shot streaming: emit each harness event as a JSON line
8//!   on stdout, then the final outcome. Mirrors TS `runPrintMode`
9//!   (`mode === "json"`) + [`json_event::toJsonEvent`].
10//! - [`interactive`] — a minimal line-oriented REPL: read prompts from stdin,
11//!   run each, print the assistant text, loop until EOF / `/exit`. v1 does NOT
12//!   port the TS `InteractiveMode` TUI (`modes/interactive/*` — a full terminal
13//!   UI with Ink/React components); this is a deliberately minimal replacement,
14//!   documented in the initial port notes (retired).
15//!
16//! All three drive the same `AgentHarness` via `AgentLane::prompt_text`.
17
18use std::io::{BufRead, IsTerminal, Write};
19use std::sync::{Arc, Mutex};
20
21use rpi_agent::events::AgentEvent;
22use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason};
23use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
24use rpi_harness::events::{HarnessEvent, RunEndOutcome};
25
26use crate::args::Args;
27use crate::remote::protocol::RemoteResponse;
28
29/// Extract the concatenated text content from an assistant message. Mirrors the
30/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
31pub fn assistant_text(msg: &AssistantMessage) -> String {
32    msg.content
33        .iter()
34        .filter_map(|c| match c {
35            Content::Text(t) => Some(t.text.clone()),
36            _ => None,
37        })
38        .collect()
39}
40
41/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
42/// ⇒ exit 1; everything else ⇒ 0.
43pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
44    match outcome {
45        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
46        _ => 0,
47    }
48}
49
50/// `print` mode: send the initial message (prompt text + inline `@file`
51/// expansions), then any follow-up messages, print the final assistant text,
52/// return the exit code. Mirrors TS `runPrintMode` (text).
53pub async fn print(
54    harness: &AgentHarness,
55    _args: &Args,
56    initial: Option<String>,
57    extra_messages: &[String],
58    initial_images: Vec<ImageContent>,
59) -> i32 {
60    let lane: Arc<dyn AgentLane> = harness.lane("main");
61
62    let mut last_exit = 0;
63    let mut last_msg: Option<AssistantMessage> = None;
64
65    // The initial prompt (and its `@file` attachments) go in one user message;
66    // extra positionals are separate prompts (mirrors the TS loop).
67    let mut prompts: Vec<String> = Vec::new();
68    if let Some(init) = initial {
69        prompts.push(init);
70    }
71    for m in extra_messages {
72        prompts.push(m.clone());
73    }
74
75    if prompts.is_empty() {
76        // Nothing to do — print mode with no prompt is a no-op success.
77        return 0;
78    }
79
80    let mut images = initial_images;
81    for prompt in prompts {
82        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
83            Ok(result) => {
84                last_exit = outcome_exit_code(&result.outcome);
85                match &result.outcome {
86                    HarnessRunOutcome::Completed { final_message, .. }
87                    | HarnessRunOutcome::Aborted { final_message, .. } => {
88                        last_msg = Some(final_message.clone());
89                    }
90                    HarnessRunOutcome::Failed {
91                        error,
92                        final_message,
93                        ..
94                    } => {
95                        if let Some(m) = final_message {
96                            if m.stop_reason == StopReason::Error {
97                                if let Some(em) = &m.error_message {
98                                    eprintln!("{em}");
99                                }
100                            }
101                        }
102                        eprintln!("run failed: {error:?}");
103                    }
104                    HarnessRunOutcome::Suspended { .. } => {
105                        eprintln!("run suspended (deferred) — resume is not supported in v1");
106                        last_exit = 1;
107                    }
108                }
109            }
110            Err(e) => {
111                eprintln!("prompt rejected: {e}");
112                return 1;
113            }
114        }
115    }
116
117    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
118    if let Some(m) = &last_msg {
119        match m.stop_reason {
120            StopReason::Error => {
121                if let Some(em) = &m.error_message {
122                    eprintln!("{em}");
123                }
124                last_exit = 1;
125            }
126            StopReason::Aborted => {
127                eprintln!("request aborted");
128                last_exit = 1;
129            }
130            _ => {
131                let text = assistant_text(m);
132                let mut out = std::io::stdout();
133                let _ = out.write_all(text.as_bytes());
134                if !text.ends_with('\n') {
135                    let _ = out.write_all(b"\n");
136                }
137                let _ = out.flush();
138            }
139        }
140    }
141
142    last_exit
143}
144
145/// `json` mode: emit each harness event as a JSON line on stdout, run the
146/// prompts, then emit a terminal `result` line carrying the outcome + final
147/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
148pub async fn json(
149    harness: &AgentHarness,
150    _args: &Args,
151    initial: Option<String>,
152    extra_messages: &[String],
153    initial_images: Vec<ImageContent>,
154    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
155) -> i32 {
156    let lane: Arc<dyn AgentLane> = harness.lane("main");
157    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
158    let collected_for_watch = collected.clone();
159
160    // A watch captures every event (RunStart fires inline during prompt_text,
161    // before a post-call listener could attach — same reason as the M5g test).
162    let mut watch = harness.events().watch(|| ());
163    watch.start(Arc::new(move |event: &HarnessEvent| {
164        // Emit each event live as JSON, and also buffer for the final summary.
165        emit_json_event(event);
166        collected_for_watch.lock().unwrap().push(event.clone());
167    }));
168    // Keep the watch alive for the whole run. Leaking is acceptable for a
169    // single-shot CLI process (the bus outlives this scope anyway).
170    std::mem::forget(watch);
171
172    // The harness bus carries run lifecycle events; the agent receiver carries
173    // the native fine-grained stream (turns, message deltas, and tools).
174    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
175    let agent_event_task = agent_events.take().map(|mut rx| {
176        tokio::spawn(async move {
177            let done_tx = agent_done_tx;
178            loop {
179                match rx.recv().await {
180                    Ok(event) => {
181                        let terminal = event.is_terminal();
182                        emit_agent_event(&event);
183                        if terminal {
184                            let _ = done_tx.send(());
185                        }
186                    }
187                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
188                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
189                }
190            }
191        })
192    });
193
194    let mut prompts: Vec<String> = Vec::new();
195    if let Some(init) = initial {
196        prompts.push(init);
197    }
198    for m in extra_messages {
199        prompts.push(m.clone());
200    }
201
202    let mut last_exit = 0;
203    let mut final_outcome: Option<HarnessRunOutcome> = None;
204
205    let mut images = initial_images;
206    for prompt in prompts {
207        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
208            Ok(result) => {
209                // The harness resolves after its run outcome, while the
210                // broadcast listener may still be scheduling the terminal
211                // AgentEnd line. Wait briefly so JSON consumers see the full
212                // lifecycle before the final result summary. The timeout is
213                // deliberately bounded for custom/older harness emitters.
214                let _ = tokio::time::timeout(
215                    std::time::Duration::from_millis(250),
216                    agent_done_rx.recv(),
217                )
218                .await;
219                last_exit = outcome_exit_code(&result.outcome);
220                final_outcome = Some(result.outcome);
221            }
222            Err(e) => {
223                // Emit a structured error line + exit.
224                let line = serde_json::json!({
225                    "type": "error",
226                    "error": e.to_string(),
227                });
228                println!("{line}");
229                if let Some(task) = agent_event_task {
230                    task.abort();
231                }
232                return 1;
233            }
234        }
235    }
236
237    // Terminal result summary.
238    let (outcome_str, final_text) = match final_outcome {
239        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
240            ("completed", Some(assistant_text(&final_message)))
241        }
242        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
243            ("aborted", Some(assistant_text(&final_message)))
244        }
245        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
246            let t = final_message.as_ref().map(assistant_text);
247            ("failed", t)
248        }
249        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
250        None => ("idle", None),
251    };
252    let result_line = serde_json::json!({
253        "type": "result",
254        "outcome": outcome_str,
255        "finalText": final_text,
256    });
257    println!("{result_line}");
258    if let Some(task) = agent_event_task {
259        task.abort();
260    }
261    last_exit
262}
263
264fn emit_agent_event(event: &AgentEvent) {
265    println!("{}", agent_event_json(event));
266}
267
268/// `rpc` mode: a headless JSONL command loop (the server-side agent).
269///
270/// Reads one JSON command per line from stdin (`{id?, type, ...}`), drives the
271/// `main` lane, and writes one JSON object per line to stdout:
272/// - fine-grained `AgentEvent`s via [`agent_event_json`] (each carries `type`),
273/// - a `{"type":"response","id":<id>,"status":"ok"|"error",...}` per command.
274///
275/// Commands: `prompt` (streams the run), `abort`, `get_state`, `set_model`,
276/// `set_thinking_level`, `set_active_tools`, `ping`, `stop`. `stop` (or stdin
277/// EOF) ends the loop. This is the process `rpi-server` spawns and that
278/// `rpi --connect` talks to. Mirrors TS `runRpcMode` / `modes/rpc`.
279///
280/// The extension `UiDialog` mailbox is bridged over the same channel: the host
281/// emits `{"type":"ui_dialog_request","requestId","toolCallId","ui"}`, and the
282/// client answers with `{"type":"ui_dialog_response","requestId","answer"}` (or
283/// `"cancelled":true`). A `prompt` runs in its own task so this loop keeps
284/// reading — that is what lets `abort` and dialog answers arrive mid-run.
285pub async fn rpc(
286    harness: &AgentHarness,
287    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
288    model_catalog: Vec<rpi_ai::Model>,
289    ui_dialog: rpi_extensions::UiDialogMailbox,
290) -> i32 {
291    rpc_with_io(
292        harness,
293        agent_events.take(),
294        model_catalog,
295        ui_dialog,
296        tokio::io::BufReader::new(tokio::io::stdin()),
297        std::io::stdout(),
298    )
299    .await
300}
301
302async fn rpc_with_io<R, W>(
303    harness: &AgentHarness,
304    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
305    model_catalog: Vec<rpi_ai::Model>,
306    ui_dialog: rpi_extensions::UiDialogMailbox,
307    input: R,
308    output: W,
309) -> i32
310where
311    R: tokio::io::AsyncBufRead + Unpin,
312    W: Write + Send + 'static,
313{
314    use tokio::io::AsyncBufReadExt;
315
316    let lane: Arc<dyn AgentLane> = harness.lane("main");
317    // One blocking writer owns stdout. Producers only enqueue bounded messages,
318    // so pipe backpressure never blocks a Tokio worker on IO or a std mutex.
319    let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
320    let pump_stop = tokio_util::sync::CancellationToken::new();
321
322    // A completion response is queued only after its final events, so a
323    // subsequent prompt cannot overtake the previous run's agent_end.
324    let (event_flush_tx, mut event_flush_rx) =
325        tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
326    // Stream fine-grained AgentEvents as JSONL until the receiver closes.
327    let event_task = agent_events.take().map(|mut rx| {
328        let out = stdout.clone();
329        let stop = pump_stop.clone();
330        tokio::spawn(async move {
331            loop {
332                let received = tokio::select! {
333                    _ = stop.cancelled() => {
334                        drain_rpc_events(&out, &mut rx).await;
335                        break;
336                    }
337                    Some(done) = event_flush_rx.recv() => {
338                        drain_rpc_events(&out, &mut rx).await;
339                        let _ = done.send(());
340                        continue;
341                    }
342                    event = rx.recv() => event,
343                };
344                match received {
345                    Ok(event) => write_line(&out, &agent_event_json(&event)).await,
346                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
347                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
348                }
349            }
350        })
351    });
352
353    let event_flush = event_task.as_ref().map(|_| event_flush_tx);
354
355    // Bridge the extension `UiDialog` mailbox over the rpc channel so a
356    // permission-gate extension can ask the client a question mid-run: we emit
357    // `{"type":"ui_dialog_request",…}` and answer it from a
358    // `{"type":"ui_dialog_response",…}` on stdin. Without this a headless run
359    // leaves the mailbox detached and every `UiDialog` call fails.
360    ui_dialog.attach();
361    let dialog_task = {
362        let mailbox = ui_dialog.clone();
363        let out = stdout.clone();
364        let stop = pump_stop.clone();
365        tokio::spawn(async move {
366            let mut shown = std::collections::HashSet::new();
367            while !stop.is_cancelled() {
368                while let Some(request) = mailbox.take_pending() {
369                    let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
370                        request_id: request.request_id.clone(),
371                        tool_call_id: request.tool_call_id,
372                        ui: request.ui,
373                    };
374                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
375                    shown.insert(request.request_id);
376                }
377                let closed: Vec<String> = shown
378                    .iter()
379                    .filter(|id| !mailbox.is_pending(id))
380                    .cloned()
381                    .collect();
382                for request_id in closed {
383                    shown.remove(&request_id);
384                    let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
385                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
386                }
387                tokio::select! {
388                    _ = stop.cancelled() => break,
389                    _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
390                }
391            }
392        })
393    };
394
395    // A `prompt` runs in a spawned task so this loop keeps reading stdin while
396    // the run is active — otherwise `abort` and `ui_dialog_response` could not
397    // be delivered mid-run. Only one prompt may be in flight at a time.
398    let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
399    let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
400    let mut run_cancel = tokio_util::sync::CancellationToken::new();
401    let mut cancel_on_exit = false;
402
403    // Readiness marker: a host/client can wait for the first line.
404    write_line(&stdout, &serde_json::json!({"type": "ready"})).await;
405
406    let mut lines = input.lines();
407    loop {
408        let incoming = tokio::select! {
409            _ = writer_failed.cancelled() => {
410                cancel_on_exit = true;
411                break;
412            }
413            line = lines.next_line() => line,
414        };
415        let line = match incoming {
416            Ok(Some(line)) => line,
417            Ok(None) => break, // EOF
418            Err(error) => {
419                eprintln!("[rpi] rpc stdin error: {error}");
420                cancel_on_exit = true;
421                break;
422            }
423        };
424        let line = line.trim();
425        if line.is_empty() {
426            continue;
427        }
428        let msg: serde_json::Value = match serde_json::from_str(line) {
429            Ok(value) => value,
430            Err(error) => {
431                write_line(
432                    &stdout,
433                    &RemoteResponse::error(
434                        serde_json::Value::Null,
435                        format!("invalid json: {error}"),
436                    )
437                    .to_json(),
438                )
439                .await;
440                continue;
441            }
442        };
443        let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
444        let kind = msg
445            .get("type")
446            .and_then(|v| v.as_str())
447            .unwrap_or("")
448            .to_string();
449
450        match kind.as_str() {
451            "ui_dialog_response" => {
452                let request_id = msg
453                    .get("requestId")
454                    .and_then(|v| v.as_str())
455                    .unwrap_or("")
456                    .to_string();
457                let result = if msg
458                    .get("cancelled")
459                    .and_then(|v| v.as_bool())
460                    .unwrap_or(false)
461                {
462                    ui_dialog.cancel(&request_id)
463                } else {
464                    let answer = msg
465                        .get("answer")
466                        .cloned()
467                        .unwrap_or(serde_json::Value::Null);
468                    ui_dialog.respond(&request_id, answer)
469                };
470                if msg.get("id").is_some() {
471                    let response = match result {
472                        Ok(()) => RemoteResponse::ok(
473                            id,
474                            serde_json::json!({"accepted":true,"requestId":request_id}),
475                        ),
476                        Err(error) => RemoteResponse::error(id, error),
477                    };
478                    write_line(&stdout, &response.to_json()).await;
479                } else if let Err(error) = result {
480                    eprintln!("[rpi] ui_dialog_response: {error}");
481                }
482                continue;
483            }
484            "stop" => {
485                cancel_on_exit = true;
486                write_line(
487                    &stdout,
488                    &RemoteResponse::ok(id.clone(), serde_json::json!({"stopped": true})).to_json(),
489                )
490                .await;
491                break;
492            }
493            "prompt" => {
494                if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
495                    write_line(
496                        &stdout,
497                        &RemoteResponse::error(id.clone(), "a run is already active").to_json(),
498                    )
499                    .await;
500                    continue;
501                }
502                if let Some(task) = run_task.take() {
503                    let _ = task.await;
504                }
505                run_cancel = tokio_util::sync::CancellationToken::new();
506                let cancel_task = run_cancel.clone();
507                let content = msg
508                    .get("content")
509                    .or_else(|| msg.get("prompt"))
510                    .and_then(|v| v.as_str())
511                    .map(str::to_owned);
512                let lane_task = Arc::clone(&lane);
513                let out_task = stdout.clone();
514                let active_task = Arc::clone(&run_active);
515                let mailbox_task = ui_dialog.clone();
516                let id_task = id.clone();
517                let flush_events = event_flush.clone();
518                run_task = Some(tokio::spawn(async move {
519                    let result = match content {
520                        Some(content) => {
521                            rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
522                        }
523                        None => Err("prompt requires a string `content`".to_string()),
524                    };
525                    let response = match result {
526                        Ok(value) => RemoteResponse::ok(id_task, value),
527                        Err(error) => RemoteResponse::error(id_task, error),
528                    };
529                    if let Some(flush) = flush_events {
530                        let (done, drained) = tokio::sync::oneshot::channel();
531                        if flush.send(done).is_ok() {
532                            let _ = drained.await;
533                        }
534                    }
535                    write_line(&out_task, &response.to_json()).await;
536                    // A finished/aborted run leaves no dialog outstanding.
537                    mailbox_task.cancel_all();
538                    active_task.store(false, std::sync::atomic::Ordering::Release);
539                }));
540                continue;
541            }
542            _ => {}
543        }
544
545        let result = match kind.as_str() {
546            "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
547                // This also covers abort arriving before the task enters the lane.
548                run_cancel.cancel();
549                Ok(serde_json::json!({"aborted": true}))
550            }
551            "abort" => lane
552                .abort()
553                .await
554                .map(|_| serde_json::json!({"aborted": true}))
555                .map_err(|e| e.to_string()),
556            "get_state" => Ok(rpc_get_state(&lane, harness).await),
557            "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
558            "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
559            "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
560            "ping" => Ok(serde_json::json!({"pong": true})),
561            other => Err(format!("unknown command type: {other}")),
562        };
563
564        let response = match result {
565            Ok(value) => RemoteResponse::ok(id.clone(), value),
566            Err(error) => RemoteResponse::error(id.clone(), error),
567        };
568        write_line(&stdout, &response.to_json()).await;
569    }
570
571    // EOF closes the input half of the protocol, not an accepted prompt.
572    // There can be no more dialog answers, so release approvals, then await the
573    // run before SessionShutdown can unload plugins or the runtime exits.
574    ui_dialog.detach();
575    if cancel_on_exit {
576        run_cancel.cancel();
577    }
578    let mut exit_code = 0;
579    if let Some(task) = run_task {
580        if let Err(error) = task.await {
581            eprintln!("[rpi] rpc prompt task failed: {error}");
582            exit_code = 1;
583        }
584    }
585    pump_stop.cancel();
586    let _ = dialog_task.await;
587    if let Some(task) = event_task {
588        let _ = task.await;
589    }
590    // All producers have joined; close the queue and flush every queued line.
591    drop(stdout);
592    match writer_task.await {
593        Ok(Ok(())) => {}
594        error => {
595            eprintln!("[rpi] rpc output failed: {error:?}");
596            exit_code = 1;
597        }
598    }
599    exit_code
600}
601
602#[derive(Clone)]
603struct RpcWriter(tokio::sync::mpsc::Sender<Vec<u8>>);
604
605fn start_rpc_writer<W: Write + Send + 'static>(
606    mut output: W,
607) -> (
608    RpcWriter,
609    tokio::task::JoinHandle<std::io::Result<()>>,
610    tokio_util::sync::CancellationToken,
611) {
612    let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
613    let failed = tokio_util::sync::CancellationToken::new();
614    let failure_signal = failed.clone();
615    let task = tokio::task::spawn_blocking(move || {
616        let result = (|| {
617            while let Some(line) = rx.blocking_recv() {
618                output.write_all(&line)?;
619                output.flush()?;
620            }
621            output.flush()
622        })();
623        if result.is_err() {
624            failure_signal.cancel();
625        }
626        result
627    });
628    (RpcWriter(tx), task, failed)
629}
630
631async fn write_line(out: &RpcWriter, value: &serde_json::Value) {
632    let mut line = value.to_string().into_bytes();
633    line.push(b'\n');
634    let _ = out.0.send(line).await;
635}
636
637async fn drain_rpc_events(
638    out: &RpcWriter,
639    events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
640) {
641    loop {
642        match events.try_recv() {
643            Ok(event) => write_line(out, &agent_event_json(&event)).await,
644            Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
645            Err(_) => break,
646        }
647    }
648}
649
650/// Cancellation is delivered even if abort/stop arrives before prompt_text has
651/// registered a run. Once active, await its normal aborted outcome and cleanup.
652async fn rpc_prompt_cancellable(
653    lane: &Arc<dyn AgentLane>,
654    content: &str,
655    cancel: tokio_util::sync::CancellationToken,
656) -> Result<serde_json::Value, String> {
657    let prompt = rpc_prompt_text(lane, content);
658    tokio::pin!(prompt);
659    tokio::select! {
660        biased;
661        _ = cancel.cancelled() => {
662            match lane.abort().await {
663                Ok(_) => prompt.await,
664                Err(_) => Err("Operation aborted".to_string()),
665            }
666        }
667        result = &mut prompt => result,
668    }
669}
670
671fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>) {
672    match outcome {
673        HarnessRunOutcome::Completed { final_message, .. } => {
674            ("completed", Some(assistant_text(final_message)))
675        }
676        HarnessRunOutcome::Aborted { final_message, .. } => {
677            ("aborted", Some(assistant_text(final_message)))
678        }
679        HarnessRunOutcome::Failed { final_message, .. } => {
680            ("failed", final_message.as_ref().map(assistant_text))
681        }
682        HarnessRunOutcome::Suspended { .. } => ("suspended", None),
683    }
684}
685
686async fn rpc_prompt_text(
687    lane: &Arc<dyn AgentLane>,
688    content: &str,
689) -> Result<serde_json::Value, String> {
690    let result = lane
691        .prompt_text(content, Vec::new())
692        .await
693        .map_err(|e| e.to_string())?;
694    let (outcome, final_text) = rpc_outcome_str(&result.outcome);
695    Ok(serde_json::json!({"outcome": outcome, "finalText": final_text}))
696}
697
698async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
699    let model = lane.get_model().await.ok().map(|m| m.id);
700    let thinking = lane
701        .get_thinking_level()
702        .await
703        .ok()
704        .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
705    let active_tools = lane.get_active_tools().await.unwrap_or_default();
706    let leaf_id = lane.get_leaf_id().await.ok().flatten();
707    serde_json::json!({
708        "model": model,
709        "thinkingLevel": thinking,
710        "activeTools": active_tools,
711        "leafId": leaf_id,
712        "sessionId": harness.session().storage().metadata().id,
713    })
714}
715
716async fn rpc_set_model(
717    lane: &Arc<dyn AgentLane>,
718    catalog: &[rpi_ai::Model],
719    msg: &serde_json::Value,
720) -> Result<serde_json::Value, String> {
721    let id = msg
722        .get("model")
723        .and_then(|v| v.as_str())
724        .ok_or("set_model requires `model`")?;
725    let model = catalog
726        .iter()
727        .find(|m| m.id.eq_ignore_ascii_case(id))
728        .cloned()
729        .ok_or_else(|| format!("unknown model: {id}"))?;
730    lane.set_model(model).await.map_err(|e| e.to_string())?;
731    Ok(serde_json::json!({"model": id}))
732}
733
734async fn rpc_set_thinking_level(
735    lane: &Arc<dyn AgentLane>,
736    msg: &serde_json::Value,
737) -> Result<serde_json::Value, String> {
738    let value = msg
739        .get("level")
740        .or_else(|| msg.get("thinkingLevel"))
741        .ok_or("set_thinking_level requires `level`")?;
742    let level: rpi_ai::types::ThinkingLevel =
743        serde_json::from_value(value.clone()).map_err(|_| {
744            "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
745        })?;
746    lane.set_thinking_level(level)
747        .await
748        .map_err(|e| e.to_string())?;
749    Ok(serde_json::json!({"thinkingLevel": level}))
750}
751
752async fn rpc_set_active_tools(
753    lane: &Arc<dyn AgentLane>,
754    msg: &serde_json::Value,
755) -> Result<serde_json::Value, String> {
756    let tools = msg
757        .get("tools")
758        .and_then(|v| v.as_array())
759        .map(|arr| {
760            arr.iter()
761                .filter_map(|v| v.as_str().map(str::to_owned))
762                .collect::<Vec<_>>()
763        })
764        .ok_or("set_active_tools requires a `tools` array")?;
765    lane.set_active_tools(tools.clone())
766        .await
767        .map_err(|e| e.to_string())?;
768    Ok(serde_json::json!({"activeTools": tools}))
769}
770
771/// Stable JSON projection for the fine-grained agent lifecycle stream.
772///
773/// Delegates to the shared [`crate::remote::protocol::RemoteEvent`] so the
774/// server and the `--connect` client agree on the wire shape by construction.
775fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
776    serde_json::to_value(crate::remote::protocol::RemoteEvent::from_agent_event(
777        event,
778    ))
779    .unwrap_or(serde_json::Value::Null)
780}
781
782/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
783/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
784/// payload's key fields).
785fn emit_json_event(event: &HarnessEvent) {
786    let line = match event {
787        HarnessEvent::CompactionProgress(e) => {
788            serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
789        }
790        HarnessEvent::RunStart(e) => serde_json::json!({
791            "type": "run_start",
792            "lane": e.lane,
793            "runId": e.run_id,
794        }),
795        HarnessEvent::RunEnd(e) => serde_json::json!({
796            "type": "run_end",
797            "lane": e.lane,
798            "runId": e.run_id,
799            "outcome": run_end_outcome_str(e.outcome),
800            "leafId": e.leaf_id,
801        }),
802    };
803    println!("{line}");
804}
805
806fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
807    match o {
808        RunEndOutcome::Completed => "completed",
809        RunEndOutcome::Aborted => "aborted",
810        RunEndOutcome::Failed => "failed",
811    }
812}
813
814/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
815///
816/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
817/// render streaming responses). The REPL fallback ignores it.
818///
819/// `model_catalog` is the resolved provider's full model list, passed through
820/// so the TUI's `/model` selector can display available models (read-only —
821/// v1 does not switch models mid-session; see the initial port notes (retired)).
822pub async fn interactive(
823    harness: &AgentHarness,
824    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
825    args: &Args,
826    model_catalog: Vec<rpi_ai::Model>,
827    initial: Option<String>,
828    extra_messages: &[String],
829    initial_images: Vec<ImageContent>,
830    theme: Option<&str>,
831    no_themes: bool,
832    reload_context: &crate::session::ReloadContext,
833) -> i32 {
834    // ---- Session lifecycle (P1): BeforeTuiStart ----
835    // `BeforeTuiStart`: extensions may prepare (connect a server, load a
836    // resource) before the interactive UI initializes. Runs with a per-event
837    // timeout; a handler returning `EVENT_HANDLER_ABORT` **vetoes** startup —
838    // we print the reason and exit without entering the TUI. No-op when no
839    // extension subscribes.
840    if let Some(reason) = crate::session::dispatch_session_event_async(
841        reload_context,
842        rpi_plugin_sdk::EventTag::BeforeTuiStart,
843    )
844    .await
845    {
846        eprintln!("[rpi] startup vetoed by extension: {reason}");
847        return crate::app::EXIT_VETOED;
848    }
849
850    // Check if TUI is supported
851    let force_tui = std::env::var("RPI_FORCE_TUI")
852        .map(|v| v == "1")
853        .unwrap_or(false);
854    let code = if force_tui || crate::interactive_tui::is_tui_supported() {
855        // Use TUI-based interactive mode
856        crate::interactive_tui::interactive_tui(
857            harness,
858            event_rx,
859            args,
860            model_catalog,
861            initial,
862            extra_messages,
863            initial_images,
864            theme,
865            no_themes,
866            reload_context,
867        )
868        .await
869    } else {
870        // Fall back to simple REPL
871        interactive_repl(harness, args, initial, extra_messages, initial_images).await
872    };
873
874    // `SessionShutdown`: the interactive session closed (TUI/REPL returned) —
875    // extensions release resources, disconnect, persist state. Fired after the
876    // UI returns so it always runs (even on early-return paths inside the TUI).
877    // A veto here is **advisory** (the session is already closing) — warn and
878    // continue.
879    if let Some(reason) = crate::session::dispatch_session_event_async(
880        reload_context,
881        rpi_plugin_sdk::EventTag::SessionShutdown,
882    )
883    .await
884    {
885        tracing::warn!(
886            "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
887        );
888    }
889
890    code
891}
892
893/// Simple REPL-based interactive mode (fallback for non-TTY environments).
894pub async fn interactive_repl(
895    harness: &AgentHarness,
896    #[allow(unused_variables)] args: &Args,
897    initial: Option<String>,
898    extra_messages: &[String],
899    initial_images: Vec<ImageContent>,
900) -> i32 {
901    // Debug: confirm we entered REPL mode
902    let lane: Arc<dyn AgentLane> = harness.lane("main");
903    let stdin = std::io::stdin();
904    let is_tty = stdin.is_terminal();
905
906    if is_tty {
907        println!(
908            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
909        );
910    }
911
912    // Run the initial prompt + extra messages first (same as print mode).
913    let mut prompts: Vec<String> = Vec::new();
914    if let Some(init) = initial {
915        prompts.push(init);
916    }
917    for m in extra_messages {
918        prompts.push(m.clone());
919    }
920    let mut images = initial_images;
921    for prompt in prompts {
922        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
923            return code;
924        }
925    }
926
927    // Then read lines from stdin until EOF / `/exit`.
928    let mut line = String::new();
929    loop {
930        if is_tty {
931            print!("> ");
932            let _ = std::io::stdout().flush();
933        }
934        line.clear();
935        match stdin.lock().read_line(&mut line) {
936            Ok(0) => break, // EOF
937            Ok(_) => {}
938            Err(_) => break,
939        }
940        let trimmed = line.trim();
941        if trimmed.is_empty() {
942            continue;
943        }
944        if trimmed == "/exit" || trimmed == "/quit" {
945            break;
946        }
947        if trimmed == "/abort" {
948            let _ = lane.abort().await;
949            eprintln!("(aborted)");
950            continue;
951        }
952        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
953            return code;
954        }
955    }
956    0
957}
958
959/// Run a single prompt in interactive mode, printing the assistant reply (or
960/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
961/// hard rejection.
962async fn run_one(
963    lane: &Arc<dyn AgentLane>,
964    prompt: &str,
965    images: Vec<ImageContent>,
966) -> Result<(), i32> {
967    match lane.prompt_text(prompt, images).await {
968        Ok(result) => {
969            match &result.outcome {
970                HarnessRunOutcome::Completed { final_message, .. }
971                | HarnessRunOutcome::Aborted { final_message, .. } => {
972                    let text = assistant_text(final_message);
973                    if !text.is_empty() {
974                        println!("{text}");
975                    }
976                }
977                HarnessRunOutcome::Failed {
978                    error,
979                    final_message,
980                    ..
981                } => {
982                    if let Some(m) = final_message {
983                        if let Some(em) = &m.error_message {
984                            eprintln!("error: {em}");
985                        }
986                    }
987                    eprintln!("run failed: {error:?}");
988                }
989                HarnessRunOutcome::Suspended { .. } => {
990                    eprintln!("run suspended (deferred) — resume not supported in v1");
991                }
992            }
993            Ok(())
994        }
995        Err(e) => {
996            eprintln!("prompt rejected: {e}");
997            Err(1)
998        }
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use rpi_agent::events::AgentEvent;
1006    use rpi_agent::types::AgentToolResult;
1007    use rpi_ai::types::{
1008        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
1009    };
1010    use rpi_harness::session::types::OperationError;
1011
1012    #[derive(Clone, Default)]
1013    struct RpcOutput(Arc<Mutex<Vec<u8>>>);
1014
1015    impl Write for RpcOutput {
1016        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1017            self.0.lock().unwrap().extend_from_slice(bytes);
1018            Ok(bytes.len())
1019        }
1020        fn flush(&mut self) -> std::io::Result<()> {
1021            Ok(())
1022        }
1023    }
1024
1025    impl RpcOutput {
1026        fn messages(&self) -> Vec<serde_json::Value> {
1027            String::from_utf8(self.0.lock().unwrap().clone())
1028                .unwrap()
1029                .lines()
1030                .map(|line| serde_json::from_str(line).unwrap())
1031                .collect()
1032        }
1033
1034        async fn wait_for(
1035            &self,
1036            predicate: impl Fn(&serde_json::Value) -> bool,
1037        ) -> serde_json::Value {
1038            tokio::time::timeout(std::time::Duration::from_secs(3), async {
1039                loop {
1040                    if let Some(message) = self.messages().into_iter().find(&predicate) {
1041                        return message;
1042                    }
1043                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1044                }
1045            })
1046            .await
1047            .expect("RPC message not received")
1048        }
1049    }
1050
1051    async fn rpc_test_harness(
1052        text: &str,
1053        tokens_per_second: f64,
1054    ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
1055        let provider = rpi_ai::providers::faux::FauxProvider::new(
1056            rpi_ai::providers::faux::FauxScript::new()
1057                .with_text(text)
1058                .with_tokens_per_second(tokens_per_second),
1059        );
1060        let options = rpi_harness::types::AgentHarnessOptions {
1061            model: provider.default_model().clone(),
1062            models: vec![provider.clone()],
1063            ..Default::default()
1064        };
1065        (AgentHarness::create(options).await.unwrap(), provider)
1066    }
1067
1068    fn start_test_rpc(
1069        harness: AgentHarness,
1070        ui: rpi_extensions::UiDialogMailbox,
1071    ) -> (
1072        tokio::io::DuplexStream,
1073        RpcOutput,
1074        tokio::task::JoinHandle<i32>,
1075    ) {
1076        let (client, input) = tokio::io::duplex(4096);
1077        let output = RpcOutput::default();
1078        let captured = output.clone();
1079        let task = tokio::spawn(async move {
1080            rpc_with_io(
1081                &harness,
1082                None,
1083                vec![],
1084                ui,
1085                tokio::io::BufReader::new(input),
1086                captured,
1087            )
1088            .await
1089        });
1090        (client, output, task)
1091    }
1092
1093    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1094    async fn rpc_eof_waits_for_accepted_prompt() {
1095        use tokio::io::AsyncWriteExt;
1096        let (harness, _) = rpc_test_harness("completed", 100.0).await;
1097        let ui = rpi_extensions::UiDialogMailbox::new();
1098        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1099        client
1100            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1101            .await
1102            .unwrap();
1103        client.shutdown().await.unwrap();
1104        assert_eq!(
1105            tokio::time::timeout(std::time::Duration::from_secs(3), task)
1106                .await
1107                .unwrap()
1108                .unwrap(),
1109            0
1110        );
1111        let response = output
1112            .messages()
1113            .into_iter()
1114            .find(|m| m["id"] == 1)
1115            .unwrap();
1116        assert_eq!(response["result"]["outcome"], "completed");
1117        assert_eq!(response["result"]["finalText"], "completed");
1118        assert!(!ui.is_attached());
1119    }
1120
1121    #[tokio::test]
1122    async fn rpc_eof_drains_queued_final_events() {
1123        let harness = AgentHarness::create(Default::default()).await.unwrap();
1124        let (tx, rx) = tokio::sync::broadcast::channel(256);
1125        for _ in 0..100 {
1126            tx.send(AgentEvent::AgentStart).unwrap();
1127        }
1128        tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
1129        let output = RpcOutput::default();
1130        let code = rpc_with_io(
1131            &harness,
1132            Some(rx),
1133            vec![],
1134            rpi_extensions::UiDialogMailbox::new(),
1135            tokio::io::BufReader::new(&b""[..]),
1136            output.clone(),
1137        )
1138        .await;
1139        assert_eq!(code, 0);
1140        let messages = output.messages();
1141        assert_eq!(
1142            messages.len(),
1143            102,
1144            "ready plus every queued event must be flushed"
1145        );
1146        assert_eq!(messages.last().unwrap()["type"], "agent_end");
1147    }
1148
1149    struct BackpressuredOutput {
1150        entered: Arc<std::sync::atomic::AtomicBool>,
1151        release: Arc<std::sync::atomic::AtomicBool>,
1152        output: RpcOutput,
1153    }
1154
1155    impl Write for BackpressuredOutput {
1156        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1157            self.entered
1158                .store(true, std::sync::atomic::Ordering::SeqCst);
1159            while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
1160                std::thread::sleep(std::time::Duration::from_millis(1));
1161            }
1162            self.output.write(bytes)
1163        }
1164        fn flush(&mut self) -> std::io::Result<()> {
1165            Ok(())
1166        }
1167    }
1168
1169    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1170    async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
1171        use std::sync::atomic::{AtomicBool, Ordering};
1172        let release = Arc::new(AtomicBool::new(false));
1173        struct ReleaseOnDrop(Arc<AtomicBool>);
1174        impl Drop for ReleaseOnDrop {
1175            fn drop(&mut self) {
1176                self.0.store(true, Ordering::SeqCst);
1177            }
1178        }
1179        let _release = ReleaseOnDrop(release.clone());
1180        let entered = Arc::new(AtomicBool::new(false));
1181        let output = RpcOutput::default();
1182        let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
1183            entered: entered.clone(),
1184            release: release.clone(),
1185            output: output.clone(),
1186        });
1187        write_line(&writer, &serde_json::json!({"id": 0})).await;
1188        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1189            while !entered.load(Ordering::SeqCst) {
1190                tokio::task::yield_now().await;
1191            }
1192        })
1193        .await
1194        .unwrap();
1195        let producer_writer = writer.clone();
1196        let producer = tokio::spawn(async move {
1197            for id in 1..=100 {
1198                write_line(&producer_writer, &serde_json::json!({"id": id})).await;
1199            }
1200        });
1201        tokio::time::timeout(
1202            std::time::Duration::from_millis(150),
1203            tokio::time::sleep(std::time::Duration::from_millis(20)),
1204        )
1205        .await
1206        .unwrap();
1207        assert!(
1208            !producer.is_finished(),
1209            "the bounded queue must apply backpressure"
1210        );
1211        release.store(true, Ordering::SeqCst);
1212        producer.await.unwrap();
1213        drop(writer);
1214        task.await.unwrap().unwrap();
1215        let messages = output.messages();
1216        assert_eq!(messages.len(), 101);
1217        for (index, message) in messages.iter().enumerate() {
1218            assert_eq!(message["id"], index);
1219        }
1220    }
1221
1222    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1223    async fn rpc_completion_response_follows_final_event_under_backpressure() {
1224        use std::sync::atomic::{AtomicBool, Ordering};
1225        let (harness, _) = rpc_test_harness("completed", 0.0).await;
1226        let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
1227        harness
1228            .set_agent_emitter(Some(Arc::new(emitter)))
1229            .await
1230            .unwrap();
1231        let output = RpcOutput::default();
1232        let release = Arc::new(AtomicBool::new(false));
1233        let release_writer = release.clone();
1234        let release_thread = std::thread::spawn(move || {
1235            std::thread::sleep(std::time::Duration::from_millis(50));
1236            release_writer.store(true, Ordering::SeqCst);
1237        });
1238        let code = rpc_with_io(
1239            &harness,
1240            Some(events),
1241            vec![],
1242            rpi_extensions::UiDialogMailbox::new(),
1243            tokio::io::BufReader::new(
1244                &b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n"[..],
1245            ),
1246            BackpressuredOutput {
1247                entered: Arc::new(AtomicBool::new(false)),
1248                release,
1249                output: output.clone(),
1250            },
1251        )
1252        .await;
1253        release_thread.join().unwrap();
1254        assert_eq!(code, 0);
1255        let messages = output.messages();
1256        let end = messages
1257            .iter()
1258            .position(|m| m["type"] == "agent_end")
1259            .unwrap();
1260        let response = messages.iter().position(|m| m["id"] == 1).unwrap();
1261        assert!(
1262            end < response,
1263            "a new prompt must not overtake the previous agent_end"
1264        );
1265        assert_eq!(messages[response]["result"]["outcome"], "completed");
1266    }
1267
1268    #[tokio::test]
1269    async fn rpc_output_failure_closes_without_waiting_for_more_input() {
1270        struct BrokenOutput;
1271        impl Write for BrokenOutput {
1272            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
1273                Err(std::io::Error::new(
1274                    std::io::ErrorKind::BrokenPipe,
1275                    "closed client output",
1276                ))
1277            }
1278            fn flush(&mut self) -> std::io::Result<()> {
1279                Ok(())
1280            }
1281        }
1282        let harness = AgentHarness::create(Default::default()).await.unwrap();
1283        let (_client, input) = tokio::io::duplex(4096);
1284        let ui = rpi_extensions::UiDialogMailbox::new();
1285        let code = tokio::time::timeout(
1286            std::time::Duration::from_secs(2),
1287            rpc_with_io(
1288                &harness,
1289                None,
1290                vec![],
1291                ui.clone(),
1292                tokio::io::BufReader::new(input),
1293                BrokenOutput,
1294            ),
1295        )
1296        .await
1297        .unwrap();
1298        assert_eq!(code, 1);
1299        assert!(!ui.is_attached());
1300    }
1301
1302    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1303    async fn rpc_stop_aborts_and_joins_active_prompt() {
1304        use tokio::io::AsyncWriteExt;
1305        let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
1306        let lane = harness.lane("main");
1307        let (mut client, output, task) =
1308            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
1309        client
1310            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1311            .await
1312            .unwrap();
1313        tokio::time::timeout(std::time::Duration::from_secs(3), async {
1314            while provider
1315                .state()
1316                .call_count
1317                .load(std::sync::atomic::Ordering::SeqCst)
1318                == 0
1319            {
1320                tokio::task::yield_now().await;
1321            }
1322        })
1323        .await
1324        .unwrap();
1325        client
1326            .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
1327            .await
1328            .unwrap();
1329        assert_eq!(
1330            tokio::time::timeout(std::time::Duration::from_secs(3), task)
1331                .await
1332                .unwrap()
1333                .unwrap(),
1334            0
1335        );
1336        let messages = output.messages();
1337        assert_eq!(
1338            messages.iter().find(|m| m["id"] == 1).unwrap()["result"]["outcome"],
1339            "aborted"
1340        );
1341        assert_eq!(
1342            messages.iter().find(|m| m["id"] == 2).unwrap()["result"]["stopped"],
1343            true
1344        );
1345        assert!(
1346            lane.abort().await.is_err(),
1347            "RPC returned with an active lane"
1348        );
1349    }
1350
1351    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1352    async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
1353        use tokio::io::AsyncWriteExt;
1354        let (harness, _) = rpc_test_harness("not completed", 20.0).await;
1355        let lane = harness.lane("main");
1356        let (mut client, output, task) =
1357            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
1358        client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
1359        assert_eq!(
1360            tokio::time::timeout(std::time::Duration::from_secs(3), task)
1361                .await
1362                .unwrap()
1363                .unwrap(),
1364            0
1365        );
1366        let response = output
1367            .messages()
1368            .into_iter()
1369            .find(|m| m["id"] == 1)
1370            .unwrap();
1371        assert!(
1372            response["result"]["outcome"] == "aborted" || response["error"] == "Operation aborted",
1373            "{response}"
1374        );
1375        assert!(lane.abort().await.is_err());
1376    }
1377
1378    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1379    async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
1380        use tokio::io::AsyncWriteExt;
1381        let (harness, _) =
1382            rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
1383        let ui = rpi_extensions::UiDialogMailbox::new();
1384        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1385        output.wait_for(|m| m["type"] == "ready").await;
1386        client
1387            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1388            .await
1389            .unwrap();
1390        ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
1391            .unwrap();
1392        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
1393        client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
1394        output.wait_for(|m| m["id"] == 2).await;
1395        assert_eq!(ui.poll("approval").unwrap()["answer"], true);
1396        output
1397            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
1398            .await;
1399        client.shutdown().await.unwrap();
1400        assert_eq!(
1401            tokio::time::timeout(std::time::Duration::from_secs(3), task)
1402                .await
1403                .unwrap()
1404                .unwrap(),
1405            0
1406        );
1407        assert_eq!(
1408            output
1409                .messages()
1410                .into_iter()
1411                .find(|m| m["id"] == 1)
1412                .unwrap()["result"]["outcome"],
1413            "completed"
1414        );
1415    }
1416
1417    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1418    async fn rpc_notifies_client_when_an_approval_is_cancelled() {
1419        use tokio::io::AsyncWriteExt;
1420        let harness = AgentHarness::create(Default::default()).await.unwrap();
1421        let ui = rpi_extensions::UiDialogMailbox::new();
1422        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1423        output.wait_for(|m| m["type"] == "ready").await;
1424        ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
1425            .unwrap();
1426        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
1427        ui.cancel_tool_call("c");
1428        output
1429            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
1430            .await;
1431        // The notification must not consume the cancellation meant for the plugin.
1432        assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
1433        client.shutdown().await.unwrap();
1434        assert_eq!(task.await.unwrap(), 0);
1435    }
1436
1437    #[tokio::test]
1438    async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
1439        use crate::remote::protocol::RemoteCommand;
1440        use tokio::io::AsyncWriteExt;
1441        let harness = AgentHarness::create(Default::default()).await.unwrap();
1442        let ui = rpi_extensions::UiDialogMailbox::new();
1443        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1444        output.wait_for(|m| m["type"] == "ready").await;
1445        for (request_id, answer, response_id) in [
1446            (
1447                "answered",
1448                Some(serde_json::json!({"values":["No"],"kind":"selector"})),
1449                5,
1450            ),
1451            ("cancelled", None, 6),
1452        ] {
1453            ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
1454                .unwrap();
1455            output
1456                .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
1457                .await;
1458            let command = RemoteCommand::UiDialogResponse {
1459                request_id: request_id.into(),
1460                cancelled: answer.is_none(),
1461                answer,
1462            }
1463            .with_id(response_id);
1464            client
1465                .write_all(format!("{command}\n").as_bytes())
1466                .await
1467                .unwrap();
1468            let ack = output.wait_for(|m| m["id"] == response_id).await;
1469            assert_eq!(ack["result"]["accepted"], true);
1470            let result = ui.poll(request_id).unwrap();
1471            if request_id == "answered" {
1472                assert_eq!(result["answer"]["values"][0], "No");
1473            } else {
1474                assert_eq!(result["status"], "cancelled");
1475            }
1476        }
1477        client.shutdown().await.unwrap();
1478        assert_eq!(task.await.unwrap(), 0);
1479    }
1480
1481    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
1482        AssistantMessage {
1483            role: rpi_ai::types::AssistantRole,
1484            content: vec![Content::Text(TextContent {
1485                kind: TextContentType,
1486                text: text.into(),
1487                text_signature: None,
1488            })],
1489            api: rpi_ai::Api::AnthropicMessages,
1490            provider: "anthropic".into(),
1491            model: "claude-sonnet-5".into(),
1492            response_model: None,
1493            response_id: None,
1494            usage: Usage::zero(),
1495            stop_reason: stop,
1496            deferred: None,
1497            error_message: None,
1498            raw_stop_reason: None,
1499            end_turn: None,
1500            timestamp: 0,
1501        }
1502    }
1503
1504    #[test]
1505    fn assistant_text_concatenates_text_blocks() {
1506        let m = assistant("hello", StopReason::Stop);
1507        assert_eq!(assistant_text(&m), "hello");
1508    }
1509
1510    #[test]
1511    fn outcome_exit_code_maps_failed_aborted_to_1() {
1512        let failed = HarnessRunOutcome::Failed {
1513            leaf_id: "l".into(),
1514            error: OperationError {
1515                code: "boom".into(),
1516                message: "boom".into(),
1517            },
1518            final_entry_id: None,
1519            final_message: None,
1520        };
1521        assert_eq!(outcome_exit_code(&failed), 1);
1522        let completed = HarnessRunOutcome::Completed {
1523            leaf_id: "l".into(),
1524            final_entry_id: "e".into(),
1525            final_message: assistant("ok", StopReason::Stop),
1526        };
1527        assert_eq!(outcome_exit_code(&completed), 0);
1528    }
1529
1530    #[test]
1531    fn run_end_outcome_str_roundtrip() {
1532        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
1533        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
1534        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
1535    }
1536
1537    #[test]
1538    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
1539        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
1540        assert_eq!(end["type"], "agent_end");
1541        assert_eq!(end["messages"], serde_json::json!([]));
1542
1543        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
1544            tool_call_id: "call-1".into(),
1545            tool_name: "read".into(),
1546            result: AgentToolResult::text("hello"),
1547            is_error: false,
1548        });
1549        assert_eq!(tool["type"], "tool_execution_end");
1550        assert_eq!(tool["result"]["content"][0]["text"], "hello");
1551        assert_eq!(tool["result"]["terminate"], false);
1552
1553        let retry = agent_event_json(&AgentEvent::RetryScheduled {
1554            attempt: 3,
1555            max_retries: 10,
1556            delay_ms: 8_000,
1557            error: "503 service unavailable".into(),
1558        });
1559        assert_eq!(retry["type"], "retry_scheduled");
1560        assert_eq!(retry["attempt"], 3);
1561        assert_eq!(retry["maxRetries"], 10);
1562        assert_eq!(retry["delayMs"], 8_000);
1563        assert_eq!(retry["error"], "503 service unavailable");
1564    }
1565}