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, UserMessage};
23use rpi_harness::agent_harness::{
24    AgentHarness, AgentLane, CompactionOutcome, CompactionResult, HarnessRunOutcome,
25};
26use rpi_harness::events::{HarnessEvent, RunEndOutcome};
27use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery, LaneRecord, RecordQuery};
28
29use crate::args::Args;
30use crate::rpc::protocol as rpc_protocol;
31use crate::rpc::writer::{start_rpc_writer, write_agent_event, write_line, RpcWriter};
32
33/// Extract the concatenated text content from an assistant message. Mirrors the
34/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
35pub fn assistant_text(msg: &AssistantMessage) -> String {
36    msg.content
37        .iter()
38        .filter_map(|c| match c {
39            Content::Text(t) => Some(t.text.clone()),
40            _ => None,
41        })
42        .collect()
43}
44
45/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
46/// ⇒ exit 1; everything else ⇒ 0.
47pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
48    match outcome {
49        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
50        _ => 0,
51    }
52}
53
54/// `print` mode: send the initial message (prompt text + inline `@file`
55/// expansions), then any follow-up messages, print the final assistant text,
56/// return the exit code. Mirrors TS `runPrintMode` (text).
57pub async fn print(
58    harness: &AgentHarness,
59    _args: &Args,
60    initial: Option<String>,
61    extra_messages: &[String],
62    initial_images: Vec<ImageContent>,
63) -> i32 {
64    let lane: Arc<dyn AgentLane> = harness.lane("main");
65
66    let mut last_exit = 0;
67    let mut last_msg: Option<AssistantMessage> = None;
68
69    // The initial prompt (and its `@file` attachments) go in one user message;
70    // extra positionals are separate prompts (mirrors the TS loop).
71    let mut prompts: Vec<String> = Vec::new();
72    if let Some(init) = initial {
73        prompts.push(init);
74    }
75    for m in extra_messages {
76        prompts.push(m.clone());
77    }
78
79    if prompts.is_empty() {
80        // Nothing to do — print mode with no prompt is a no-op success.
81        return 0;
82    }
83
84    let mut images = initial_images;
85    for prompt in prompts {
86        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
87            Ok(result) => {
88                last_exit = outcome_exit_code(&result.outcome);
89                match &result.outcome {
90                    HarnessRunOutcome::Completed { final_message, .. }
91                    | HarnessRunOutcome::Aborted { final_message, .. } => {
92                        last_msg = Some(final_message.clone());
93                    }
94                    HarnessRunOutcome::Failed {
95                        error,
96                        final_message,
97                        ..
98                    } => {
99                        if let Some(m) = final_message {
100                            if m.stop_reason == StopReason::Error {
101                                if let Some(em) = &m.error_message {
102                                    eprintln!("{em}");
103                                }
104                            }
105                        }
106                        eprintln!("run failed: {error:?}");
107                    }
108                    HarnessRunOutcome::Suspended { .. } => {
109                        eprintln!("run suspended (deferred) — resume is not supported in v1");
110                        last_exit = 1;
111                    }
112                }
113            }
114            Err(e) => {
115                eprintln!("prompt rejected: {e}");
116                return 1;
117            }
118        }
119    }
120
121    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
122    if let Some(m) = &last_msg {
123        match m.stop_reason {
124            StopReason::Error => {
125                if let Some(em) = &m.error_message {
126                    eprintln!("{em}");
127                }
128                last_exit = 1;
129            }
130            StopReason::Aborted => {
131                eprintln!("request aborted");
132                last_exit = 1;
133            }
134            _ => {
135                let text = assistant_text(m);
136                let mut out = std::io::stdout();
137                let _ = out.write_all(text.as_bytes());
138                if !text.ends_with('\n') {
139                    let _ = out.write_all(b"\n");
140                }
141                let _ = out.flush();
142            }
143        }
144    }
145
146    last_exit
147}
148
149/// `json` mode: emit each harness event as a JSON line on stdout, run the
150/// prompts, then emit a terminal `result` line carrying the outcome + final
151/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
152pub async fn json(
153    harness: &AgentHarness,
154    _args: &Args,
155    initial: Option<String>,
156    extra_messages: &[String],
157    initial_images: Vec<ImageContent>,
158    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
159) -> i32 {
160    let lane: Arc<dyn AgentLane> = harness.lane("main");
161    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
162    let collected_for_watch = collected.clone();
163
164    // A watch captures every event (RunStart fires inline during prompt_text,
165    // before a post-call listener could attach — same reason as the M5g test).
166    let mut watch = harness.events().watch(|| ());
167    watch.start(Arc::new(move |event: &HarnessEvent| {
168        // Emit each event live as JSON, and also buffer for the final summary.
169        emit_json_event(event);
170        collected_for_watch.lock().unwrap().push(event.clone());
171    }));
172    // Keep the watch alive for the whole run. Leaking is acceptable for a
173    // single-shot CLI process (the bus outlives this scope anyway).
174    std::mem::forget(watch);
175
176    // The harness bus carries run lifecycle events; the agent receiver carries
177    // the native fine-grained stream (turns, message deltas, and tools).
178    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
179    let agent_event_task = agent_events.take().map(|mut rx| {
180        tokio::spawn(async move {
181            let done_tx = agent_done_tx;
182            loop {
183                match rx.recv().await {
184                    Ok(event) => {
185                        let terminal = event.is_terminal();
186                        emit_agent_event(&event);
187                        if terminal {
188                            let _ = done_tx.send(());
189                        }
190                    }
191                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
192                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
193                }
194            }
195        })
196    });
197
198    let mut prompts: Vec<String> = Vec::new();
199    if let Some(init) = initial {
200        prompts.push(init);
201    }
202    for m in extra_messages {
203        prompts.push(m.clone());
204    }
205
206    let mut last_exit = 0;
207    let mut final_outcome: Option<HarnessRunOutcome> = None;
208
209    let mut images = initial_images;
210    for prompt in prompts {
211        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
212            Ok(result) => {
213                // The harness resolves after its run outcome, while the
214                // broadcast listener may still be scheduling the terminal
215                // AgentEnd line. Wait briefly so JSON consumers see the full
216                // lifecycle before the final result summary. The timeout is
217                // deliberately bounded for custom/older harness emitters.
218                let _ = tokio::time::timeout(
219                    std::time::Duration::from_millis(250),
220                    agent_done_rx.recv(),
221                )
222                .await;
223                last_exit = outcome_exit_code(&result.outcome);
224                final_outcome = Some(result.outcome);
225            }
226            Err(e) => {
227                // Emit a structured error line + exit.
228                let line = serde_json::json!({
229                    "type": "error",
230                    "error": e.to_string(),
231                });
232                println!("{line}");
233                if let Some(task) = agent_event_task {
234                    task.abort();
235                }
236                return 1;
237            }
238        }
239    }
240
241    // Terminal result summary.
242    let (outcome_str, final_text) = match final_outcome {
243        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
244            ("completed", Some(assistant_text(&final_message)))
245        }
246        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
247            ("aborted", Some(assistant_text(&final_message)))
248        }
249        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
250            let t = final_message.as_ref().map(assistant_text);
251            ("failed", t)
252        }
253        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
254        None => ("idle", None),
255    };
256    let result_line = serde_json::json!({
257        "type": "result",
258        "outcome": outcome_str,
259        "finalText": final_text,
260    });
261    println!("{result_line}");
262    if let Some(task) = agent_event_task {
263        task.abort();
264    }
265    last_exit
266}
267
268fn emit_agent_event(event: &AgentEvent) {
269    println!("{}", agent_event_json(event));
270}
271
272/// `rpc` mode: a headless JSONL command loop (the server-side agent).
273///
274/// Reads one JSON command per line from stdin (`{id?, type, ...}`), drives the
275/// `main` lane, and writes one JSON object per line to stdout:
276/// - fine-grained `AgentEvent`s via [`agent_event_json`] (each carries `type`),
277/// - a `{"type":"response","id":<id>,"status":"ok"|"error",...}` per command.
278///
279/// Commands: `prompt`, `resume_pending`, and `compact` (drive the lane),
280/// `abort`, `get_state`, `set_model`,
281/// `set_thinking_level`, `set_active_tools`, `ping`, `stop`. `stop` (or stdin
282/// EOF) ends the loop. This is the process `rpi-server` spawns and that
283/// `rpi --connect` talks to. Mirrors TS `runRpcMode` / `modes/rpc`.
284///
285/// The extension `UiDialog` mailbox is bridged over the same channel: the host
286/// emits `{"type":"ui_dialog_request","requestId","toolCallId","ui"}`, and the
287/// client answers with `{"type":"ui_dialog_response","requestId","answer"}` (or
288/// `"cancelled":true`). A `prompt` runs in its own task so this loop keeps
289/// reading — that is what lets `abort` and dialog answers arrive mid-run.
290pub async fn rpc(
291    harness: &AgentHarness,
292    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
293    model_catalog: Vec<rpi_ai::Model>,
294    ui_dialog: rpi_extensions::UiDialogMailbox,
295    oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
296) -> i32 {
297    rpc_with_io(
298        harness,
299        agent_events.take(),
300        model_catalog,
301        ui_dialog,
302        oauth_snapshot,
303        tokio::io::BufReader::new(tokio::io::stdin()),
304        std::io::stdout(),
305    )
306    .await
307}
308
309async fn rpc_with_io<R, W>(
310    harness: &AgentHarness,
311    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
312    model_catalog: Vec<rpi_ai::Model>,
313    ui_dialog: rpi_extensions::UiDialogMailbox,
314    oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
315    input: R,
316    output: W,
317) -> i32
318where
319    R: tokio::io::AsyncBufRead + Unpin,
320    W: Write + Send + 'static,
321{
322    let mut command_tasks = Vec::new();
323    let lane: Arc<dyn AgentLane> = harness.lane("main");
324    // One blocking writer owns stdout. Producers only enqueue bounded messages,
325    // so pipe backpressure never blocks a Tokio worker on IO or a std mutex.
326    let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
327    let pump_stop = tokio_util::sync::CancellationToken::new();
328
329    // A completion response is queued only after its final events, so a
330    // subsequent prompt cannot overtake the previous run's agent_end.
331    let (event_flush_tx, mut event_flush_rx) =
332        tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
333    // Stream fine-grained AgentEvents as JSONL until the receiver closes.
334    let event_task = agent_events.take().map(|mut rx| {
335        let out = stdout.clone();
336        let stop = pump_stop.clone();
337        tokio::spawn(async move {
338            loop {
339                let received = tokio::select! {
340                    _ = stop.cancelled() => {
341                        drain_rpc_events(&out, &mut rx).await;
342                        break;
343                    }
344                    Some(done) = event_flush_rx.recv() => {
345                        drain_rpc_events(&out, &mut rx).await;
346                        let _ = done.send(());
347                        continue;
348                    }
349                    event = rx.recv() => event,
350                };
351                match received {
352                    Ok(event) => write_agent_event(&out, &event).await,
353                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
354                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
355                }
356            }
357        })
358    });
359
360    let event_flush = event_task.as_ref().map(|_| event_flush_tx);
361
362    // Bridge the extension `UiDialog` mailbox over the rpc channel so a
363    // permission-gate extension can ask the client a question mid-run: we emit
364    // `{"type":"ui_dialog_request",…}` and answer it from a
365    // `{"type":"ui_dialog_response",…}` on stdin. Without this a headless run
366    // leaves the mailbox detached and every `UiDialog` call fails.
367    ui_dialog.attach();
368    let dialog_task = {
369        let mailbox = ui_dialog.clone();
370        let out = stdout.clone();
371        let stop = pump_stop.clone();
372        tokio::spawn(async move {
373            let mut shown = std::collections::HashSet::new();
374            while !stop.is_cancelled() {
375                while let Some(request) = mailbox.take_pending() {
376                    let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
377                        request_id: request.request_id.clone(),
378                        tool_call_id: request.tool_call_id,
379                        ui: request.ui,
380                    };
381                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
382                    shown.insert(request.request_id);
383                }
384                let closed: Vec<String> = shown
385                    .iter()
386                    .filter(|id| !mailbox.is_pending(id))
387                    .cloned()
388                    .collect();
389                for request_id in closed {
390                    shown.remove(&request_id);
391                    let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
392                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
393                }
394                tokio::select! {
395                    _ = stop.cancelled() => break,
396                    _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
397                }
398            }
399        })
400    };
401
402    // A `prompt` runs in a spawned task so this loop keeps reading stdin while
403    // the run is active — otherwise `abort` and `ui_dialog_response` could not
404    // be delivered mid-run. Only one prompt may be in flight at a time.
405    let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
406    let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
407    let mut run_cancel = tokio_util::sync::CancellationToken::new();
408    let mut cancel_on_exit = false;
409
410    use tokio::io::AsyncBufReadExt;
411
412    let mut lines = input.lines();
413    write_line(&stdout, &rpc_protocol::ready()).await;
414
415    loop {
416        let incoming = tokio::select! {
417            _ = writer_failed.cancelled() => {
418                cancel_on_exit = true;
419                break;
420            }
421            line = lines.next_line() => line,
422        };
423        let line = match incoming {
424            Ok(Some(line)) => line,
425            Ok(None) => break, // EOF
426            Err(error) => {
427                eprintln!("[rpi] rpc stdin error: {error}");
428                cancel_on_exit = true;
429                break;
430            }
431        };
432        let line = line.trim();
433        if line.is_empty() {
434            continue;
435        }
436        let msg: serde_json::Value = match serde_json::from_str(line) {
437            Ok(value) => value,
438            Err(error) => {
439                write_line(
440                    &stdout,
441                    &rpc_protocol::response(
442                        None,
443                        "invalid_request",
444                        Err(format!("invalid json: {error}")),
445                    ),
446                )
447                .await;
448                continue;
449            }
450        };
451        let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
452        let kind = msg
453            .get("type")
454            .and_then(|v| v.as_str())
455            .unwrap_or("")
456            .to_string();
457
458        if rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
459            && serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
460        {
461            write_line(
462                &stdout,
463                &rpc_protocol::response(
464                    Some(id.clone()),
465                    &kind,
466                    Err("invalid command payload".to_string()),
467                ),
468            )
469            .await;
470            continue;
471        }
472
473        match kind.as_str() {
474            "ui_dialog_response" => {
475                let request_id = msg
476                    .get("requestId")
477                    .and_then(|v| v.as_str())
478                    .unwrap_or("")
479                    .to_string();
480                let result = if msg
481                    .get("cancelled")
482                    .and_then(|v| v.as_bool())
483                    .unwrap_or(false)
484                {
485                    ui_dialog.cancel(&request_id)
486                } else {
487                    let answer = msg
488                        .get("answer")
489                        .cloned()
490                        .unwrap_or(serde_json::Value::Null);
491                    ui_dialog.respond(&request_id, answer)
492                };
493                if msg.get("id").is_some() {
494                    let response = match result {
495                        Ok(()) => rpc_protocol::response(
496                            Some(id),
497                            "ui_dialog_response",
498                            Ok(Some(
499                                serde_json::json!({"accepted":true,"requestId":request_id}),
500                            )),
501                        ),
502                        Err(error) => {
503                            rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
504                        }
505                    };
506                    write_line(&stdout, &response).await;
507                } else if let Err(error) = result {
508                    eprintln!("[rpi] ui_dialog_response: {error}");
509                }
510                continue;
511            }
512            "execute_command" => {
513                command_tasks.retain(|task: &tokio::task::JoinHandle<()>| !task.is_finished());
514                let snapshot = oauth_snapshot.clone();
515                let request = msg.clone();
516                let out = stdout.clone();
517                command_tasks.push(tokio::spawn(async move {
518                    let result = tokio::task::spawn_blocking(move || {
519                        rpc_execute_command(snapshot.as_deref(), &request)
520                    })
521                    .await
522                    .unwrap_or_else(|_| Err("command task failed".into()));
523                    write_line(
524                        &out,
525                        &rpc_protocol::response(Some(id), "execute_command", result.map(Some)),
526                    )
527                    .await;
528                }));
529                continue;
530            }
531            "stop" => {
532                cancel_on_exit = true;
533                write_line(
534                    &stdout,
535                    &rpc_protocol::response(
536                        Some(id.clone()),
537                        "stop",
538                        Ok(Some(serde_json::json!({"stopped": true}))),
539                    ),
540                )
541                .await;
542                break;
543            }
544            "prompt" | "resume_pending" | "compact" => {
545                let resume = kind == "resume_pending";
546                let compact = kind == "compact";
547                if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
548                    write_line(
549                        &stdout,
550                        &rpc_protocol::response(
551                            Some(id.clone()),
552                            &kind,
553                            Err("a run is already active".to_string()),
554                        ),
555                    )
556                    .await;
557                    continue;
558                }
559                if let Some(task) = run_task.take() {
560                    let _ = task.await;
561                }
562                run_cancel = tokio_util::sync::CancellationToken::new();
563                let cancel_task = run_cancel.clone();
564                // Resuming spends the tokens the interrupted run never did, so it
565                // only proceeds when recovery actually left a pending run.
566                if resume && !lane.has_pending_resume().await {
567                    run_active.store(false, std::sync::atomic::Ordering::Release);
568                    write_line(
569                        &stdout,
570                        &rpc_protocol::response(
571                            Some(id),
572                            "resume_pending",
573                            Ok(Some(serde_json::json!({"resumed": false}))),
574                        ),
575                    )
576                    .await;
577                    continue;
578                }
579                let content = msg
580                    .get("message")
581                    .or_else(|| msg.get("content"))
582                    .or_else(|| msg.get("prompt"))
583                    .and_then(|v| v.as_str())
584                    .map(str::to_owned);
585                let lane_task = Arc::clone(&lane);
586                let out_task = stdout.clone();
587                let active_task = Arc::clone(&run_active);
588                let mailbox_task = ui_dialog.clone();
589                let flush_events = event_flush.clone();
590                let Some(content) = content.or_else(|| (resume || compact).then(String::new))
591                else {
592                    active_task.store(false, std::sync::atomic::Ordering::Release);
593                    write_line(
594                        &stdout,
595                        &rpc_protocol::response(
596                            Some(id),
597                            "prompt",
598                            Err("prompt requires a string `message`".to_string()),
599                        ),
600                    )
601                    .await;
602                    continue;
603                };
604                write_line(
605                    &stdout,
606                    &rpc_protocol::response(
607                        Some(id),
608                        &kind,
609                        Ok(Some(serde_json::json!({"accepted": true}))),
610                    ),
611                )
612                .await;
613                let instructions = msg
614                    .get("customInstructions")
615                    .and_then(|v| v.as_str())
616                    .map(str::to_owned);
617                run_task = Some(tokio::spawn(async move {
618                    let result = if compact {
619                        rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
620                    } else if resume {
621                        rpc_resume_cancellable(&lane_task, cancel_task).await
622                    } else {
623                        rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
624                    };
625                    if let Some(flush) = flush_events {
626                        let (done, drained) = tokio::sync::oneshot::channel();
627                        if flush.send(done).is_ok() {
628                            let _ = drained.await;
629                        }
630                    }
631                    let (outcome, aborted, error) = match &result {
632                        Ok(value) => {
633                            let outcome = value
634                                .get("outcome")
635                                .and_then(|v| v.as_str())
636                                .unwrap_or("completed")
637                                .to_string();
638                            let error = value
639                                .get("error")
640                                .and_then(|v| v.as_str())
641                                .map(str::to_owned);
642                            (outcome.clone(), outcome == "aborted", error)
643                        }
644                        Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
645                    };
646                    // Recovery may leave a continuable run behind an abort, so the
647                    // settlement carries what the client needs to offer "continue".
648                    let pending_resume = lane_task.has_pending_resume().await;
649                    let summary = result
650                        .as_ref()
651                        .ok()
652                        .and_then(|value| value.get("summary"))
653                        .cloned();
654                    let settled = serde_json::json!({
655                        "type": "agent_settled",
656                        "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
657                        "outcome": outcome,
658                        "aborted": aborted,
659                        "error": error,
660                        "summary": summary,
661                        "pendingResume": pending_resume,
662                    });
663                    write_line(&out_task, &settled).await;
664                    mailbox_task.cancel_all();
665                    active_task.store(false, std::sync::atomic::Ordering::Release);
666                }));
667                continue;
668            }
669            _ => {}
670        }
671
672        let result = match kind.as_str() {
673            "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
674                // This also covers abort arriving before the task enters the lane.
675                run_cancel.cancel();
676                Ok(serde_json::json!({"aborted": true}))
677            }
678            "abort" => lane
679                .abort()
680                .await
681                .map(|_| serde_json::json!({"aborted": true}))
682                .map_err(|e| e.to_string()),
683            "get_state" => Ok(rpc_get_state(&lane, harness).await),
684            "get_commands" => Ok(rpc_get_commands(oauth_snapshot.as_deref())),
685            "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
686            "get_history" => rpc_get_history(&lane, harness, &msg).await,
687            "get_session_branch" => rpc_get_session_branch(harness).await,
688            "append_entry" => rpc_append_entry(harness, &msg).await,
689            "get_stats" => rpc_get_stats(&lane, harness).await,
690            "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
691            "queue_message" => rpc_queue_message(&lane, &msg).await,
692            "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
693            "clear_queue" => rpc_clear_queue(&lane).await,
694            "navigate" => rpc_navigate(&lane, &msg).await,
695            "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
696            "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
697            "oauth" => rpc_oauth(&oauth_snapshot, &msg),
698            "set_api_key" => rpc_set_api_key(&msg).await,
699            "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
700            "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
701            "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
702            "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
703            "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
704            "ping" => Ok(serde_json::json!({"pong": true})),
705            other => Err(format!("unknown command type: {other}")),
706        };
707
708        let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
709        write_line(&stdout, &response).await;
710    }
711
712    // EOF closes the input half of the protocol, not an accepted prompt.
713    // There can be no more dialog answers, so release approvals, then await the
714    // run before SessionShutdown can unload plugins or the runtime exits.
715    ui_dialog.detach();
716    if cancel_on_exit {
717        run_cancel.cancel();
718    }
719    for task in command_tasks {
720        let _ = task.await;
721    }
722    let mut exit_code = 0;
723    if let Some(task) = run_task {
724        if let Err(error) = task.await {
725            eprintln!("[rpi] rpc prompt task failed: {error}");
726            exit_code = 1;
727        }
728    }
729    pump_stop.cancel();
730    let _ = dialog_task.await;
731    if let Some(task) = event_task {
732        let _ = task.await;
733    }
734    // All producers have joined; close the queue and flush every queued line.
735    drop(stdout);
736    match writer_task.await {
737        Ok(Ok(())) => {}
738        error => {
739            eprintln!("[rpi] rpc output failed: {error:?}");
740            exit_code = 1;
741        }
742    }
743    exit_code
744}
745
746fn rpc_get_commands(snapshot: Option<&rpi_extensions::RegistrySnapshot>) -> serde_json::Value {
747    let commands: Vec<_> = snapshot
748        .into_iter()
749        .flat_map(|s| s.commands())
750        .map(
751            |command| serde_json::json!({"name": command.name, "description": command.description}),
752        )
753        .collect();
754    serde_json::json!({"commands": commands})
755}
756
757fn rpc_execute_command(
758    snapshot: Option<&rpi_extensions::RegistrySnapshot>,
759    request: &serde_json::Value,
760) -> Result<serde_json::Value, String> {
761    let name = request
762        .get("command")
763        .and_then(|v| v.as_str())
764        .filter(|name| !name.trim_start_matches('/').is_empty())
765        .ok_or("command must be a non-empty string")?;
766    let args = match request.get("args") {
767        None => "",
768        Some(value) => value.as_str().ok_or("args must be a string")?,
769    };
770    let command = snapshot
771        .into_iter()
772        .flat_map(|s| s.commands())
773        .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
774        .ok_or_else(|| format!("unknown command: {name}"))?;
775    let input = serde_json::json!({"command": name, "args": args}).to_string();
776    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
777        let mut out = rpi_plugin_sdk::StbString::empty();
778        let rc = (command.handler)(
779            rpi_plugin_sdk::StbStringRef::from_str(&input),
780            &mut out,
781            command.user_data,
782        );
783        // SAFETY: the callback transfers its output using the host allocator,
784        // matching the TUI command invocation contract.
785        let out = unsafe {
786            rpi_plugin_sdk::callbacks::OwnedString::from_raw(
787                out,
788                Some(rpi_extensions::host_free_string),
789            )
790        };
791        if rc != 0 {
792            return Err(format!("command failed: {name} (code {rc})"));
793        }
794        serde_json::from_str(&out.to_string_lossy())
795            .map_err(|_| format!("command returned invalid JSON: {name}"))
796    }))
797    .unwrap_or_else(|_| Err(format!("command panicked: {name}")))
798}
799
800async fn drain_rpc_events(
801    out: &RpcWriter,
802    events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
803) {
804    loop {
805        match events.try_recv() {
806            Ok(event) => write_agent_event(out, &event).await,
807            Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
808            Err(_) => break,
809        }
810    }
811}
812
813/// Cancellation is delivered even if abort/stop arrives before prompt_text has
814/// registered a run. Once active, await its normal aborted outcome and cleanup.
815async fn rpc_prompt_cancellable(
816    lane: &Arc<dyn AgentLane>,
817    content: &str,
818    cancel: tokio_util::sync::CancellationToken,
819) -> Result<serde_json::Value, String> {
820    let prompt = rpc_prompt_text(lane, content);
821    tokio::pin!(prompt);
822    tokio::select! {
823        biased;
824        _ = cancel.cancelled() => {
825            match lane.abort().await {
826                Ok(_) => prompt.await,
827                Err(_) => Err("Operation aborted".to_string()),
828            }
829        }
830        result = &mut prompt => result,
831    }
832}
833
834/// Compact the branch, unless the caller cancels first.
835///
836/// Compaction is its own operation kind in the harness: it spends a provider
837/// call on the summary, so it takes the same single-run slot and reports the
838/// same settlement shape a prompt does.
839async fn rpc_compact_cancellable(
840    lane: &Arc<dyn AgentLane>,
841    instructions: Option<String>,
842    cancel: tokio_util::sync::CancellationToken,
843) -> Result<serde_json::Value, String> {
844    let compact = async {
845        match lane.compact(instructions.as_deref()).await {
846            Ok(result) => Ok(compaction_json(&result)),
847            Err(error) => Err(error.to_string()),
848        }
849    };
850    tokio::pin!(compact);
851    tokio::select! {
852        biased;
853        _ = cancel.cancelled() => {
854            let _ = lane.abort().await;
855            compact.await
856        }
857        result = &mut compact => result,
858    }
859}
860
861/// Reduce a compaction result to what a client needs: the verdict, and a line
862/// a transcript can print.
863fn compaction_json(result: &CompactionResult) -> serde_json::Value {
864    let mut value = match &result.outcome {
865        CompactionOutcome::Completed { leaf_id, entry } => {
866            let (tokens_before, retained) = match entry {
867                rpi_harness::session::types::Entry::Compaction(compaction) => {
868                    (compaction.tokens_before, compaction.retained_tail.len())
869                }
870                _ => (0, 0),
871            };
872            serde_json::json!({
873                "outcome": "compacted",
874                "leafId": leaf_id,
875                "tokensBefore": tokens_before,
876                "retainedMessages": retained,
877                "summary": format!(
878                    "Conversation compacted · {} tokens before, {} messages kept.",
879                    tokens_before, retained
880                ),
881            })
882        }
883        CompactionOutcome::Declined { leaf_id } => serde_json::json!({
884            "outcome": "declined",
885            "leafId": leaf_id,
886            "summary": "Nothing to compact yet.",
887        }),
888        CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
889            "outcome": "aborted",
890            "leafId": leaf_id,
891            "summary": "Compaction aborted.",
892        }),
893        CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
894            "outcome": "failed",
895            "leafId": leaf_id,
896            "error": error.message,
897            "summary": format!("Compaction failed: {}", error.message),
898        }),
899    };
900    value["runId"] = serde_json::json!(result.run_id);
901    value
902}
903
904/// Drive the run recovery left behind, unless the caller cancels first.
905///
906/// `Ok(None)` from the lane means there was nothing to resume: the run is
907/// reported as `skipped` so the client can settle without inventing an outcome.
908async fn rpc_resume_cancellable(
909    lane: &Arc<dyn AgentLane>,
910    cancel: tokio_util::sync::CancellationToken,
911) -> Result<serde_json::Value, String> {
912    let resume = async {
913        match lane.resume_pending().await {
914            Ok(Some(result)) => {
915                let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
916                Ok(serde_json::json!({
917                    "runId": result.run_id,
918                    "outcome": outcome,
919                    "finalText": final_text,
920                    "error": error,
921                    "resumed": true,
922                }))
923            }
924            Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
925            Err(error) => Err(error.to_string()),
926        }
927    };
928    tokio::pin!(resume);
929    tokio::select! {
930        biased;
931        _ = cancel.cancelled() => {
932            let _ = lane.abort().await;
933            resume.await
934        }
935        result = &mut resume => result,
936    }
937}
938
939fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
940    match outcome {
941        HarnessRunOutcome::Completed { final_message, .. } => {
942            ("completed", Some(assistant_text(final_message)), None)
943        }
944        HarnessRunOutcome::Aborted { final_message, .. } => {
945            ("aborted", Some(assistant_text(final_message)), None)
946        }
947        // A failed run can end with nothing to say, which is exactly when the
948        // reason matters: rate limits and provider errors live here.
949        HarnessRunOutcome::Failed {
950            final_message,
951            error,
952            ..
953        } => (
954            "failed",
955            final_message.as_ref().map(assistant_text),
956            Some(error.message.clone()),
957        ),
958        HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
959    }
960}
961
962async fn rpc_prompt_text(
963    lane: &Arc<dyn AgentLane>,
964    content: &str,
965) -> Result<serde_json::Value, String> {
966    let result = lane
967        .prompt_text(content, Vec::new())
968        .await
969        .map_err(|e| e.to_string())?;
970    let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
971    Ok(
972        serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
973    )
974}
975
976async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
977    let model = lane.get_model().await.ok();
978    let thinking = lane
979        .get_thinking_level()
980        .await
981        .ok()
982        .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
983    let active_tools = lane.get_active_tools().await.unwrap_or_default();
984    let leaf_id = lane.get_leaf_id().await.ok().flatten();
985    let context = rpc_context_usage(lane, harness).await;
986    let active_operation = lane
987        .snapshot()
988        .await
989        .ok()
990        .and_then(|snapshot| snapshot.active)
991        .map(|active| {
992            serde_json::json!({
993                "runId": active.run_id,
994                "lane": active.lane,
995                "kind": format!("{:?}", active.kind).to_lowercase(),
996            })
997        });
998    let queued = lane.queued_messages().await.unwrap_or_default();
999    let stats = rpc_get_stats(lane, harness).await.ok();
1000    serde_json::json!({
1001        "modelId": model.as_ref().map(|model| model.id.clone()),
1002        "model": model.as_ref().map(|model| model.id.clone()),
1003        "provider": model.as_ref().map(|model| model.provider.clone()),
1004        "thinkingLevel": thinking,
1005        "activeTools": active_tools,
1006        "leafId": leaf_id,
1007        "context": context,
1008        "sessionId": harness.session().storage().metadata().id,
1009        "activeOperation": active_operation,
1010        "queue": {
1011            "steering": queued.steering,
1012            "followUp": queued.follow_up,
1013            "steeringCount": queued.steering.len(),
1014            "followUpCount": queued.follow_up.len(),
1015        },
1016        "stats": stats,
1017    })
1018}
1019
1020fn rpc_now_ms() -> i64 {
1021    std::time::SystemTime::now()
1022        .duration_since(std::time::UNIX_EPOCH)
1023        .map(|duration| duration.as_millis() as i64)
1024        .unwrap_or_default()
1025}
1026
1027fn rpc_message_text(value: &serde_json::Value) -> String {
1028    let Some(content) = value.get("content") else {
1029        return String::new();
1030    };
1031    if let Some(text) = content.as_str() {
1032        return text.to_string();
1033    }
1034    content
1035        .as_array()
1036        .into_iter()
1037        .flatten()
1038        .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
1039        .collect::<Vec<_>>()
1040        .join("\n")
1041}
1042
1043async fn rpc_get_history(
1044    lane: &Arc<dyn AgentLane>,
1045    harness: &AgentHarness,
1046    msg: &serde_json::Value,
1047) -> Result<serde_json::Value, String> {
1048    let limit = msg
1049        .get("limit")
1050        .and_then(serde_json::Value::as_u64)
1051        .unwrap_or(100)
1052        .clamp(1, 500) as usize;
1053    let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
1054    let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
1055    let mut records = harness
1056        .session()
1057        .find_records(&RecordQuery {
1058            lane: Some(lane.name().to_string()),
1059            after_seq,
1060            order: Some(EntryOrder::OldestFirst),
1061            ..Default::default()
1062        })
1063        .await
1064        .map_err(|error| error.to_string())?;
1065    if let Some(before) = before_seq {
1066        records.retain(|record| record.seq() < before);
1067    }
1068    let total = records.len();
1069    let has_more = records.len() > limit;
1070    if before_seq.is_some() {
1071        if has_more {
1072            records = records.split_off(records.len() - limit);
1073        }
1074    } else if after_seq.is_some() {
1075        records.truncate(limit);
1076    } else if has_more {
1077        records = records.split_off(records.len() - limit);
1078    }
1079    let first_seq = records.first().map(LaneRecord::seq);
1080    let last_seq = records.last().map(LaneRecord::seq);
1081    let open_operations = harness
1082        .session()
1083        .find_open_operations(lane.name(), Some(50))
1084        .await
1085        .map_err(|error| error.to_string())?;
1086    Ok(serde_json::json!({
1087        "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1088        "total": total,
1089        "limit": limit,
1090        "afterSeq": after_seq,
1091        "beforeSeq": before_seq,
1092        "firstSeq": first_seq,
1093        "lastSeq": last_seq,
1094        "hasMore": has_more,
1095        "leafId": lane.get_leaf_id().await.ok().flatten(),
1096        "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1097    }))
1098}
1099
1100async fn rpc_get_session_branch(harness: &AgentHarness) -> Result<serde_json::Value, String> {
1101    let entries = harness
1102        .find_entries_on_branch(
1103            &EntryQuery {
1104                order: Some(EntryOrder::OldestFirst),
1105                ..Default::default()
1106            },
1107            &BranchBounds::default(),
1108        )
1109        .await
1110        .map_err(|error| error.to_string())?;
1111    let entries = entries
1112        .iter()
1113        .map(|entry| serde_json::to_value(entry).map_err(|error| error.to_string()))
1114        .collect::<Result<Vec<_>, _>>()?;
1115    Ok(serde_json::json!({"entries": entries, "count": entries.len()}))
1116}
1117
1118async fn rpc_append_entry(
1119    harness: &AgentHarness,
1120    msg: &serde_json::Value,
1121) -> Result<serde_json::Value, String> {
1122    let custom_type = msg
1123        .get("customType")
1124        .and_then(serde_json::Value::as_str)
1125        .filter(|value| !value.trim().is_empty())
1126        .ok_or_else(|| "append_entry requires a non-empty string `customType`".to_string())?;
1127    let entry_id = harness
1128        .session()
1129        .append_custom_entry(custom_type, msg.get("data").cloned())
1130        .await
1131        .map_err(|error| error.to_string())?;
1132    Ok(serde_json::json!({"entryId": entry_id, "customType": custom_type}))
1133}
1134
1135async fn rpc_get_stats(
1136    lane: &Arc<dyn AgentLane>,
1137    harness: &AgentHarness,
1138) -> Result<serde_json::Value, String> {
1139    let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1140    let records = harness
1141        .session()
1142        .find_records(&RecordQuery {
1143            lane: Some(lane.name().to_string()),
1144            order: Some(EntryOrder::OldestFirst),
1145            ..Default::default()
1146        })
1147        .await
1148        .map_err(|error| error.to_string())?;
1149    let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1150    for record in &records {
1151        let kind = match record {
1152            LaneRecord::OperationStarted(started) => {
1153                let key = started.intent.kind();
1154                *operation_counts.entry(key.to_string()).or_default() += 1;
1155                if matches!(
1156                    &started.intent,
1157                    rpi_harness::session::types::OperationIntent::Run {
1158                        resume_data: Some(_),
1159                        ..
1160                    }
1161                ) {
1162                    *operation_counts.entry("resume".to_string()).or_default() += 1;
1163                }
1164                continue;
1165            }
1166            LaneRecord::OperationFinished(_) => "operation_finished",
1167            LaneRecord::RetryPending(_) => "retry_pending",
1168            LaneRecord::Usage(_) => "usage",
1169            _ => continue,
1170        };
1171        *operation_counts.entry(kind.to_string()).or_default() += 1;
1172    }
1173    let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1174    let model = lane.get_model().await.ok();
1175    let input = stats.cached_tokens + stats.uncached_tokens;
1176    Ok(serde_json::json!({
1177        "messageCount": stats.message_count,
1178        "operationCount": count("operation_finished"),
1179        "retryCount": count("retry_pending"),
1180        "compactionCount": count("compaction"),
1181        "resumeCount": count("resume"),
1182        "operationCounts": operation_counts,
1183        "provider": model.as_ref().map(|value| value.provider.clone()),
1184        "modelId": model.as_ref().map(|value| value.id.clone()),
1185        "cachedTokens": stats.cached_tokens,
1186        "uncachedTokens": stats.uncached_tokens,
1187        "totalTokens": stats.total_tokens,
1188        "costTotal": stats.cost_total,
1189        "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1190    }))
1191}
1192
1193async fn rpc_get_queue(
1194    lane: &Arc<dyn AgentLane>,
1195    harness: &AgentHarness,
1196    msg: &serde_json::Value,
1197) -> Result<serde_json::Value, String> {
1198    let include_completed = msg
1199        .get("includeCompleted")
1200        .and_then(serde_json::Value::as_bool)
1201        .unwrap_or(false);
1202    let records = harness
1203        .session()
1204        .find_records(&RecordQuery {
1205            lane: Some(lane.name().to_string()),
1206            order: Some(EntryOrder::OldestFirst),
1207            ..Default::default()
1208        })
1209        .await
1210        .map_err(|error| error.to_string())?;
1211    let mut cancelled = std::collections::HashMap::<String, u64>::new();
1212    for record in &records {
1213        if let LaneRecord::QueueCancelled(record) = record {
1214            cancelled.insert(record.entry_id.clone(), record.base.seq);
1215        }
1216    }
1217    let mut items = Vec::new();
1218    for record in records {
1219        let LaneRecord::QueueEnqueued(record) = record else {
1220            continue;
1221        };
1222        let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1223            continue;
1224        };
1225        let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1226            ("cancelled", Some(*seq))
1227        } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1228            ("consumed", Some(entry.base().seq))
1229        } else {
1230            ("queued", None)
1231        };
1232        if status != "queued" && !include_completed {
1233            continue;
1234        }
1235        let message = record
1236            .target
1237            .get("message")
1238            .cloned()
1239            .unwrap_or(serde_json::Value::Null);
1240        items.push(serde_json::json!({
1241            "entryId": entry_id,
1242            "queue": record.queue,
1243            "status": status,
1244            "text": rpc_message_text(&message),
1245            "message": message,
1246            "runId": record.run_id,
1247            "seq": record.base.seq,
1248            "completedSeq": completed_seq,
1249        }));
1250    }
1251    Ok(
1252        serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1253    )
1254}
1255
1256async fn rpc_queue_message(
1257    lane: &Arc<dyn AgentLane>,
1258    msg: &serde_json::Value,
1259) -> Result<serde_json::Value, String> {
1260    let text = msg
1261        .get("message")
1262        .and_then(serde_json::Value::as_str)
1263        .filter(|text| !text.trim().is_empty())
1264        .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1265    let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1266    let queue = msg
1267        .get("deliverAs")
1268        .and_then(serde_json::Value::as_str)
1269        .unwrap_or("followUp");
1270    let result = match queue {
1271        "steer" => lane.steer(message).await,
1272        "followUp" | "follow_up" => lane.follow_up(message).await,
1273        "nextTurn" | "next_run" => lane.next_run(message).await,
1274        other => return Err(format!("unknown queue kind: {other}")),
1275    }
1276    .map_err(|error| error.to_string())?;
1277    Ok(serde_json::json!({
1278        "accepted": true,
1279        "entryId": result.entry_id,
1280        "queue": queue,
1281    }))
1282}
1283
1284async fn rpc_cancel_queued(
1285    lane: &Arc<dyn AgentLane>,
1286    msg: &serde_json::Value,
1287) -> Result<serde_json::Value, String> {
1288    let entry_id = msg
1289        .get("entryId")
1290        .and_then(serde_json::Value::as_str)
1291        .filter(|value| !value.is_empty())
1292        .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
1293    let result = lane
1294        .cancel_queued(entry_id)
1295        .await
1296        .map_err(|error| error.to_string())?;
1297    Ok(serde_json::json!({
1298        "entryId": entry_id,
1299        "outcome": format!("{:?}", result.outcome).to_lowercase(),
1300    }))
1301}
1302
1303async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
1304    let result = lane
1305        .clear_queue()
1306        .await
1307        .map_err(|error| error.to_string())?;
1308    Ok(serde_json::json!({
1309        "cleared": result.steering.len() + result.follow_up.len(),
1310        "steering": result.steering,
1311        "followUp": result.follow_up,
1312    }))
1313}
1314
1315async fn rpc_navigate(
1316    lane: &Arc<dyn AgentLane>,
1317    msg: &serde_json::Value,
1318) -> Result<serde_json::Value, String> {
1319    let target_id = msg
1320        .get("targetId")
1321        .and_then(serde_json::Value::as_str)
1322        .filter(|value| !value.is_empty());
1323    if lane
1324        .snapshot()
1325        .await
1326        .map_err(|error| error.to_string())?
1327        .active
1328        .is_some()
1329    {
1330        return Err("cannot navigate while an operation is active".to_string());
1331    }
1332    let result = lane
1333        .navigate_tree(target_id, false, None, None)
1334        .await
1335        .map_err(|error| error.to_string())?;
1336    let (outcome, leaf_id, error) = match result.outcome {
1337        rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
1338            ("completed", new_leaf_id, None)
1339        }
1340        rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
1341            ("declined", leaf_id, None)
1342        }
1343        rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
1344            ("aborted", leaf_id, None)
1345        }
1346        rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
1347            ("failed", leaf_id, Some(error.message))
1348        }
1349    };
1350    Ok(serde_json::json!({
1351        "runId": result.run_id,
1352        "outcome": outcome,
1353        "leafId": leaf_id,
1354        "error": error,
1355    }))
1356}
1357
1358/// How full the model's context is, measured the way compaction measures it.
1359///
1360/// The branch is projected into model input first (that is what applies a
1361/// compaction boundary), so the number matches what the next provider call will
1362/// actually send rather than how many messages the transcript happens to hold.
1363async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1364    use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
1365    use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
1366
1367    let entries = harness
1368        .find_entries_on_branch(
1369            &EntryQuery {
1370                order: Some(EntryOrder::OldestFirst),
1371                ..Default::default()
1372            },
1373            &BranchBounds::default(),
1374        )
1375        .await
1376        .unwrap_or_default();
1377    let estimate = estimate_context_tokens(&build_session_context(&entries).messages);
1378    let window = lane
1379        .get_model()
1380        .await
1381        .ok()
1382        .map(|model| model.context_window)
1383        .unwrap_or(0);
1384    let settings = harness.compaction_settings();
1385    let window = window as i64;
1386    let threshold = window - settings.reserve_tokens;
1387    let percent = if window > 0 {
1388        (estimate.tokens as f64) * 100.0 / (window as f64)
1389    } else {
1390        0.0
1391    };
1392    serde_json::json!({
1393        "tokens": estimate.tokens,
1394        "window": window,
1395        "threshold": threshold,
1396        "percent": (percent * 10.0).round() / 10.0,
1397        "reserveTokens": settings.reserve_tokens,
1398        "keepRecentTokens": settings.keep_recent_tokens,
1399        "compactionEnabled": settings.enabled,
1400        // False once a real provider usage report backs the number; until then
1401        // it is a character-based estimate.
1402        "estimated": estimate.last_usage_index.is_none(),
1403        "trailingTokens": estimate.trailing_tokens,
1404    })
1405}
1406
1407/// The window `get_messages` returns, expressed as a slice range plus whether an
1408/// older page still exists. `before` is the index of the `beforeId` entry.
1409fn page_window(
1410    total: usize,
1411    limit: usize,
1412    before: Option<usize>,
1413) -> (std::ops::Range<usize>, bool) {
1414    match before {
1415        Some(index) => {
1416            let start = index.saturating_sub(limit);
1417            (start..index, start > 0)
1418        }
1419        None => {
1420            let skip = total.saturating_sub(limit);
1421            (skip..total, skip > 0)
1422        }
1423    }
1424}
1425
1426/// Read-only snapshot of the current branch, oldest first. Messages are
1427/// projected with the same `AgentMessage` encoding the event stream uses, so a
1428/// client can render a transcript without understanding session entries.
1429async fn rpc_get_messages(
1430    lane: &Arc<dyn AgentLane>,
1431    harness: &AgentHarness,
1432    msg: &serde_json::Value,
1433) -> Result<serde_json::Value, String> {
1434    use rpi_harness::session::types::{BranchBounds, Entry, EntryOrder, EntryQuery};
1435
1436    const DEFAULT_LIMIT: usize = 400;
1437    let limit = msg
1438        .get("limit")
1439        .and_then(|value| value.as_u64())
1440        .map(|value| value as usize)
1441        .unwrap_or(DEFAULT_LIMIT)
1442        .max(1);
1443    // `beforeId` pages backwards: the returned window ends immediately before
1444    // the entry with that id, so a client can walk a long branch one page at a
1445    // time instead of always re-reading the tail.
1446    let before_id = msg
1447        .get("beforeId")
1448        .and_then(|value| value.as_str())
1449        .filter(|value| !value.is_empty())
1450        .map(str::to_owned);
1451    let entries = harness
1452        .find_entries_on_branch(
1453            &EntryQuery {
1454                order: Some(EntryOrder::OldestFirst),
1455                ..Default::default()
1456            },
1457            &BranchBounds::default(),
1458        )
1459        .await
1460        .map_err(|error| error.to_string())?;
1461    let mut items: Vec<(String, serde_json::Value)> = Vec::new();
1462    for entry in entries {
1463        match entry {
1464            Entry::Message(entry) => {
1465                let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
1466                // The session entry id, not the message's own id: it is what
1467                // `beforeId` keys on, and what makes rows stable across reloads.
1468                value["entryId"] = serde_json::json!(entry.base.id);
1469                items.push((entry.base.id, value));
1470            }
1471            // Compaction rewrites what the model sees, not the branch, so the
1472            // transcript keeps its messages. Marking the boundary is what lets a
1473            // client explain why the context suddenly shrank.
1474            Entry::Compaction(compaction) => items.push((
1475                compaction.base.id.clone(),
1476                serde_json::json!({
1477                    "role": "compaction",
1478                    "entryId": compaction.base.id,
1479                    "tokensBefore": compaction.tokens_before,
1480                    "retainedMessages": compaction.retained_tail.len(),
1481                    "timestamp": compaction.base.timestamp,
1482                }),
1483            )),
1484            _ => {}
1485        }
1486    }
1487    let total = items.len();
1488    let before_index = match &before_id {
1489        Some(before) => match items.iter().position(|(id, _)| id == before) {
1490            Some(index) => Some(index),
1491            None => return Err(format!("beforeId not found: {before}")),
1492        },
1493        None => None,
1494    };
1495    let (range, has_more) = page_window(total, limit, before_index);
1496    let window = items[range].to_vec();
1497    let first_id = window.first().map(|(id, _)| id.clone());
1498    let last_id = window.last().map(|(id, _)| id.clone());
1499    let messages = window
1500        .iter()
1501        .map(|(_, value)| value.clone())
1502        .collect::<Vec<_>>();
1503    // Read-only branch position: the leaf the snapshot ends on, and whether
1504    // recovery left an interrupted run that a client may want to surface.
1505    let leaf_id = lane.get_leaf_id().await.ok().flatten();
1506    let pending_resume = lane.has_pending_resume().await;
1507    Ok(serde_json::json!({
1508        "messages": messages,
1509        "total": total,
1510        "truncated": has_more,
1511        "limit": limit,
1512        "firstId": first_id,
1513        "lastId": last_id,
1514        "hasMore": has_more,
1515        "beforeId": before_id,
1516        "leafId": leaf_id,
1517        "pendingResume": pending_resume,
1518    }))
1519}
1520
1521fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
1522    let mut providers =
1523        std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
1524    providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
1525    if let Ok(models) = crate::config::load_models_config() {
1526        providers.extend(models.providers.keys().cloned());
1527    }
1528    providers
1529}
1530
1531fn rpc_get_auth_status(
1532    model_catalog: &[rpi_ai::Model],
1533    oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
1534) -> serde_json::Value {
1535    let store = crate::config::read_auth().unwrap_or_default();
1536    let models = crate::config::load_models_config().unwrap_or_default();
1537    let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
1538        snapshot
1539            .providers()
1540            .iter()
1541            .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
1542            .collect()
1543    });
1544    let auth_states = crate::credentials::statuses(&models, &store, &manifests);
1545    let providers = rpc_auth_provider_ids(model_catalog)
1546        .into_iter()
1547        .map(|provider| {
1548            let credential = store.get(&provider);
1549            let state = auth_states.get(&provider).cloned().unwrap_or(
1550                rpi_runtime::credentials::AuthState {
1551                    status: rpi_runtime::credentials::AuthStatus::Missing,
1552                    source: crate::credentials::CredentialSource::Missing,
1553                },
1554            );
1555            let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
1556            let oauth_available =
1557                oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
1558            serde_json::json!({
1559                "provider": provider,
1560                "ready": state.is_ready(),
1561                "source": state.source.as_json_str(),
1562                "status": state.status,
1563                "oauth": oauth,
1564                "oauthAvailable": oauth_available,
1565            })
1566        })
1567        .collect::<Vec<_>>();
1568    serde_json::json!({"providers": providers})
1569}
1570
1571fn rpc_oauth(
1572    snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
1573    msg: &serde_json::Value,
1574) -> Result<serde_json::Value, String> {
1575    let snapshot = snapshot
1576        .as_ref()
1577        .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
1578    let provider = msg
1579        .get("provider")
1580        .and_then(serde_json::Value::as_str)
1581        .map(str::trim)
1582        .filter(|value| !value.is_empty())
1583        .ok_or_else(|| "oauth requires a provider".to_string())?;
1584    let action = msg
1585        .get("action")
1586        .and_then(serde_json::Value::as_str)
1587        .map(str::trim)
1588        .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
1589        .ok_or_else(|| "oauth requires a valid action".to_string())?;
1590    let input = msg
1591        .get("input")
1592        .cloned()
1593        .unwrap_or_else(|| serde_json::json!({}));
1594    let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
1595        .map_err(|_| "OAuth provider request failed".to_string())?;
1596    let response = rpi_extensions::OAuthActionResponse::parse(raw)
1597        .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
1598    match response.kind {
1599        rpi_extensions::OAuthResponseKind::Credential => {
1600            let credential = response
1601                .credential()
1602                .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
1603                .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
1604            crate::config::upsert_oauth_token(
1605                provider,
1606                credential.access,
1607                credential.refresh,
1608                credential.expires_at,
1609            )
1610            .map_err(|_| "OAuth credentials could not be saved".to_string())?;
1611            Ok(serde_json::json!({
1612                "provider": provider,
1613                "status": "authenticated"
1614            }))
1615        }
1616        rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
1617            "provider": provider,
1618            "status": "interaction",
1619            "display": oauth_display(&response.payload)
1620        })),
1621        rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
1622            "provider": provider,
1623            "status": "success"
1624        })),
1625        rpi_extensions::OAuthResponseKind::Error => {
1626            Err("OAuth provider rejected the request".to_string())
1627        }
1628    }
1629}
1630
1631/// Keep provider-defined OAuth UI data deliberately narrow. In particular,
1632/// token-like fields never cross the RPC boundary, even if a plugin includes
1633/// them in an interaction payload by mistake.
1634fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
1635    let display = payload.get("display").unwrap_or(payload);
1636    let Some(object) = display.as_object() else {
1637        return serde_json::json!({});
1638    };
1639    let mut safe = serde_json::Map::new();
1640    for key in [
1641        "title",
1642        "url",
1643        "instructions",
1644        "user_code",
1645        "verification_uri",
1646    ] {
1647        if let Some(value) = object.get(key) {
1648            safe.insert(key.to_string(), value.clone());
1649        }
1650    }
1651    serde_json::Value::Object(safe)
1652}
1653
1654async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
1655    let provider = msg
1656        .get("provider")
1657        .and_then(serde_json::Value::as_str)
1658        .map(str::trim)
1659        .filter(|value| !value.is_empty())
1660        .ok_or_else(|| "set_api_key requires a provider".to_string())?;
1661    let api_key = msg
1662        .get("apiKey")
1663        .and_then(serde_json::Value::as_str)
1664        .map(str::trim)
1665        .filter(|value| !value.is_empty())
1666        .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
1667    crate::config::upsert_credential(
1668        provider,
1669        crate::config::Credential::ApiKey {
1670            key: Some(api_key.to_string()),
1671            env: None,
1672        },
1673    )
1674    .map_err(|error| error.to_string())?;
1675    Ok(serde_json::json!({
1676        "provider": provider,
1677        "ready": true,
1678        "source": "auth.json",
1679        "requiresRestart": true,
1680    }))
1681}
1682
1683fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
1684    let models = catalog
1685        .iter()
1686        .map(crate::rpc::protocol::model_descriptor)
1687        .collect::<Vec<_>>();
1688    serde_json::json!({"models": models})
1689}
1690
1691async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
1692    let levels = lane
1693        .get_model()
1694        .await
1695        .map(|model| model.supported_thinking_levels())
1696        .unwrap_or_default();
1697    serde_json::json!({"levels": levels})
1698}
1699
1700async fn rpc_get_available_tools(
1701    lane: &Arc<dyn AgentLane>,
1702    harness: &AgentHarness,
1703) -> serde_json::Value {
1704    let active = lane.get_active_tools().await.unwrap_or_default();
1705    let tools = harness
1706        .get_tools()
1707        .await
1708        .map(|tools| {
1709            tools
1710                .iter()
1711                .map(|tool| tool.tool.schema().name.clone())
1712                .collect::<Vec<_>>()
1713        })
1714        .unwrap_or_else(|_| active.clone());
1715    serde_json::json!({"tools": tools, "activeTools": active})
1716}
1717
1718async fn rpc_set_model(
1719    lane: &Arc<dyn AgentLane>,
1720    catalog: &[rpi_ai::Model],
1721    msg: &serde_json::Value,
1722) -> Result<serde_json::Value, String> {
1723    let id = msg
1724        .get("modelId")
1725        .or_else(|| msg.get("model"))
1726        .and_then(|v| v.as_str())
1727        .ok_or("set_model requires `modelId`")?;
1728    let provider = msg.get("provider").and_then(|v| v.as_str());
1729    let model = catalog
1730        .iter()
1731        .find(|m| {
1732            m.id.eq_ignore_ascii_case(id)
1733                && provider.map_or(true, |provider| m.provider.eq_ignore_ascii_case(provider))
1734        })
1735        .cloned()
1736        .ok_or_else(|| format!("unknown model: {id}"))?;
1737    lane.set_model(model).await.map_err(|e| e.to_string())?;
1738    Ok(serde_json::json!({"modelId": id, "provider": provider}))
1739}
1740
1741async fn rpc_set_thinking_level(
1742    lane: &Arc<dyn AgentLane>,
1743    msg: &serde_json::Value,
1744) -> Result<serde_json::Value, String> {
1745    let value = msg
1746        .get("level")
1747        .or_else(|| msg.get("thinkingLevel"))
1748        .ok_or("set_thinking_level requires `level`")?;
1749    let level: rpi_ai::types::ThinkingLevel =
1750        serde_json::from_value(value.clone()).map_err(|_| {
1751            "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
1752        })?;
1753    lane.set_thinking_level(level)
1754        .await
1755        .map_err(|e| e.to_string())?;
1756    Ok(serde_json::json!({"thinkingLevel": level}))
1757}
1758
1759async fn rpc_set_active_tools(
1760    lane: &Arc<dyn AgentLane>,
1761    msg: &serde_json::Value,
1762) -> Result<serde_json::Value, String> {
1763    let tools = msg
1764        .get("tools")
1765        .and_then(|v| v.as_array())
1766        .map(|arr| {
1767            arr.iter()
1768                .filter_map(|v| v.as_str().map(str::to_owned))
1769                .collect::<Vec<_>>()
1770        })
1771        .ok_or("set_active_tools requires a `tools` array")?;
1772    lane.set_active_tools(tools.clone())
1773        .await
1774        .map_err(|e| e.to_string())?;
1775    Ok(serde_json::json!({"activeTools": tools}))
1776}
1777
1778/// Stable JSON projection for the fine-grained agent lifecycle stream.
1779///
1780/// Delegates to the shared [`crate::remote::protocol::RemoteEvent`] so the
1781/// server and the `--connect` client agree on the wire shape by construction.
1782fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
1783    crate::rpc::events::project(event)
1784}
1785
1786/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
1787/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
1788/// payload's key fields).
1789fn emit_json_event(event: &HarnessEvent) {
1790    let line = match event {
1791        HarnessEvent::CompactionProgress(e) => {
1792            serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
1793        }
1794        HarnessEvent::RunStart(e) => serde_json::json!({
1795            "type": "run_start",
1796            "lane": e.lane,
1797            "runId": e.run_id,
1798        }),
1799        HarnessEvent::RunEnd(e) => serde_json::json!({
1800            "type": "run_end",
1801            "lane": e.lane,
1802            "runId": e.run_id,
1803            "outcome": run_end_outcome_str(e.outcome),
1804            "leafId": e.leaf_id,
1805        }),
1806    };
1807    println!("{line}");
1808}
1809
1810fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
1811    match o {
1812        RunEndOutcome::Completed => "completed",
1813        RunEndOutcome::Aborted => "aborted",
1814        RunEndOutcome::Failed => "failed",
1815    }
1816}
1817
1818/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
1819///
1820/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
1821/// render streaming responses). The REPL fallback ignores it.
1822///
1823/// `model_catalog` is the resolved provider's full model list, passed through
1824/// so the TUI's `/model` selector can display available models (read-only —
1825/// v1 does not switch models mid-session; see the initial port notes (retired)).
1826pub async fn interactive(
1827    harness: &AgentHarness,
1828    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
1829    args: &Args,
1830    model_catalog: Vec<rpi_ai::Model>,
1831    initial: Option<String>,
1832    extra_messages: &[String],
1833    initial_images: Vec<ImageContent>,
1834    theme: Option<&str>,
1835    no_themes: bool,
1836    reload_context: &crate::session::ReloadContext,
1837) -> i32 {
1838    // ---- Session lifecycle (P1): BeforeTuiStart ----
1839    // `BeforeTuiStart`: extensions may prepare (connect a server, load a
1840    // resource) before the interactive UI initializes. Runs with a per-event
1841    // timeout; a handler returning `EVENT_HANDLER_ABORT` **vetoes** startup —
1842    // we print the reason and exit without entering the TUI. No-op when no
1843    // extension subscribes.
1844    if let Some(reason) = crate::session::dispatch_session_event_async(
1845        reload_context,
1846        rpi_plugin_sdk::EventTag::BeforeTuiStart,
1847    )
1848    .await
1849    {
1850        eprintln!("[rpi] startup vetoed by extension: {reason}");
1851        return crate::app::EXIT_VETOED;
1852    }
1853
1854    // Check if TUI is supported
1855    let force_tui = std::env::var("RPI_FORCE_TUI")
1856        .map(|v| v == "1")
1857        .unwrap_or(false);
1858    let code = if force_tui || crate::interactive_tui::is_tui_supported() {
1859        // Use TUI-based interactive mode
1860        crate::interactive_tui::interactive_tui(
1861            harness,
1862            event_rx,
1863            args,
1864            model_catalog,
1865            initial,
1866            extra_messages,
1867            initial_images,
1868            theme,
1869            no_themes,
1870            reload_context,
1871        )
1872        .await
1873    } else {
1874        // Fall back to simple REPL
1875        interactive_repl(harness, args, initial, extra_messages, initial_images).await
1876    };
1877
1878    // `SessionShutdown`: the interactive session closed (TUI/REPL returned) —
1879    // extensions release resources, disconnect, persist state. Fired after the
1880    // UI returns so it always runs (even on early-return paths inside the TUI).
1881    // A veto here is **advisory** (the session is already closing) — warn and
1882    // continue.
1883    if let Some(reason) = crate::session::dispatch_session_event_async(
1884        reload_context,
1885        rpi_plugin_sdk::EventTag::SessionShutdown,
1886    )
1887    .await
1888    {
1889        tracing::warn!(
1890            "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
1891        );
1892    }
1893
1894    code
1895}
1896
1897/// Simple REPL-based interactive mode (fallback for non-TTY environments).
1898pub async fn interactive_repl(
1899    harness: &AgentHarness,
1900    #[allow(unused_variables)] args: &Args,
1901    initial: Option<String>,
1902    extra_messages: &[String],
1903    initial_images: Vec<ImageContent>,
1904) -> i32 {
1905    // Debug: confirm we entered REPL mode
1906    let lane: Arc<dyn AgentLane> = harness.lane("main");
1907    let stdin = std::io::stdin();
1908    let is_tty = stdin.is_terminal();
1909
1910    if is_tty {
1911        println!(
1912            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
1913        );
1914    }
1915
1916    // Run the initial prompt + extra messages first (same as print mode).
1917    let mut prompts: Vec<String> = Vec::new();
1918    if let Some(init) = initial {
1919        prompts.push(init);
1920    }
1921    for m in extra_messages {
1922        prompts.push(m.clone());
1923    }
1924    let mut images = initial_images;
1925    for prompt in prompts {
1926        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
1927            return code;
1928        }
1929    }
1930
1931    // Then read lines from stdin until EOF / `/exit`.
1932    let mut line = String::new();
1933    loop {
1934        if is_tty {
1935            print!("> ");
1936            let _ = std::io::stdout().flush();
1937        }
1938        line.clear();
1939        match stdin.lock().read_line(&mut line) {
1940            Ok(0) => break, // EOF
1941            Ok(_) => {}
1942            Err(_) => break,
1943        }
1944        let trimmed = line.trim();
1945        if trimmed.is_empty() {
1946            continue;
1947        }
1948        if trimmed == "/exit" || trimmed == "/quit" {
1949            break;
1950        }
1951        if trimmed == "/abort" {
1952            let _ = lane.abort().await;
1953            eprintln!("(aborted)");
1954            continue;
1955        }
1956        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
1957            return code;
1958        }
1959    }
1960    0
1961}
1962
1963/// Run a single prompt in interactive mode, printing the assistant reply (or
1964/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
1965/// hard rejection.
1966async fn run_one(
1967    lane: &Arc<dyn AgentLane>,
1968    prompt: &str,
1969    images: Vec<ImageContent>,
1970) -> Result<(), i32> {
1971    match lane.prompt_text(prompt, images).await {
1972        Ok(result) => {
1973            match &result.outcome {
1974                HarnessRunOutcome::Completed { final_message, .. }
1975                | HarnessRunOutcome::Aborted { final_message, .. } => {
1976                    let text = assistant_text(final_message);
1977                    if !text.is_empty() {
1978                        println!("{text}");
1979                    }
1980                }
1981                HarnessRunOutcome::Failed {
1982                    error,
1983                    final_message,
1984                    ..
1985                } => {
1986                    if let Some(m) = final_message {
1987                        if let Some(em) = &m.error_message {
1988                            eprintln!("error: {em}");
1989                        }
1990                    }
1991                    eprintln!("run failed: {error:?}");
1992                }
1993                HarnessRunOutcome::Suspended { .. } => {
1994                    eprintln!("run suspended (deferred) — resume not supported in v1");
1995                }
1996            }
1997            Ok(())
1998        }
1999        Err(e) => {
2000            eprintln!("prompt rejected: {e}");
2001            Err(1)
2002        }
2003    }
2004}
2005
2006#[cfg(test)]
2007mod tests {
2008    use super::*;
2009    use rpi_agent::events::AgentEvent;
2010    use rpi_agent::types::AgentToolResult;
2011    use rpi_ai::types::{
2012        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
2013    };
2014    use rpi_harness::session::types::OperationError;
2015
2016    #[tokio::test]
2017    async fn rpc_commands_list_and_execute_registered_handler() {
2018        extern "C" fn echo(
2019            input: rpi_plugin_sdk::StbStringRef,
2020            out: *mut rpi_plugin_sdk::StbString,
2021            _: *mut std::ffi::c_void,
2022        ) -> i32 {
2023            // SAFETY: the RPC invoker provides live input and output pointers.
2024            unsafe {
2025                *out = rpi_plugin_sdk::StbString::from_string(input.as_str().to_owned());
2026            }
2027            0
2028        }
2029        let mut registry = rpi_extensions::ExtensionRegistry::new();
2030        registry.register_command(
2031            "echo".into(),
2032            "Echo arguments".into(),
2033            echo,
2034            std::ptr::null_mut(),
2035        );
2036        let harness = AgentHarness::create(Default::default()).await.unwrap();
2037        let output = RpcOutput::default();
2038        let input = concat!(
2039            "{\"id\":1,\"type\":\"get_commands\"}\n",
2040            "{\"id\":2,\"type\":\"execute_command\",\"command\":\"/echo\",\"args\":\"hello world\"}\n",
2041            "{\"id\":3,\"type\":\"execute_command\",\"command\":\"missing\"}\n",
2042            "{\"id\":4,\"type\":\"execute_command\",\"command\":\"echo\",\"args\":42}\n"
2043        );
2044        assert_eq!(
2045            rpc_with_io(
2046                &harness,
2047                None,
2048                vec![],
2049                rpi_extensions::UiDialogMailbox::new(),
2050                Some(Arc::new(registry.snapshot())),
2051                tokio::io::BufReader::new(input.as_bytes()),
2052                output.clone()
2053            )
2054            .await,
2055            0
2056        );
2057        let messages = output.messages();
2058        let response = |id| messages.iter().find(|m| m["id"] == id).unwrap();
2059        assert_eq!(response(1)["data"]["commands"][0]["name"], "echo");
2060        assert_eq!(
2061            response(2)["data"],
2062            serde_json::json!({"command":"/echo", "args":"hello world"})
2063        );
2064        assert_eq!(response(3)["success"], false);
2065        assert_eq!(response(4)["success"], false);
2066        assert_eq!(rpc_get_commands(None), serde_json::json!({"commands":[]}));
2067    }
2068
2069    #[test]
2070    fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
2071        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2072        struct RestoreConfig(Option<std::ffi::OsString>);
2073        impl Drop for RestoreConfig {
2074            fn drop(&mut self) {
2075                match self.0.take() {
2076                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2077                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2078                }
2079            }
2080        }
2081        let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2082        let directory = tempfile::tempdir().unwrap();
2083        std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
2084        std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
2085            "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
2086            "expired-account": {"api":"openai-completions", "models":[]}
2087        }}).to_string()).unwrap();
2088        std::fs::write(directory.path().join("auth.json"), serde_json::json!({
2089            "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
2090        }).to_string()).unwrap();
2091        let status = rpc_get_auth_status(&[], None);
2092        let providers = status["providers"].as_array().unwrap();
2093        let header = providers
2094            .iter()
2095            .find(|row| row["provider"] == "header-gateway")
2096            .unwrap();
2097        assert_eq!(header["ready"], true);
2098        assert_eq!(header["source"], "headers");
2099        assert_eq!(header["status"], "ready");
2100        let expired = providers
2101            .iter()
2102            .find(|row| row["provider"] == "expired-account")
2103            .unwrap();
2104        assert_eq!(expired["ready"], false);
2105        assert_eq!(expired["status"], "expired");
2106        assert!(!status.to_string().contains("secret"));
2107    }
2108
2109    #[test]
2110    fn oauth_display_drops_token_like_fields() {
2111        let display = oauth_display(&serde_json::json!({
2112            "display": {
2113                "title": "Sign in",
2114                "url": "https://example.test/authorize",
2115                "instructions": ["Paste the code"],
2116                "access_token": "secret-access",
2117                "refresh_token": "secret-refresh",
2118            }
2119        }));
2120        assert_eq!(display["title"], "Sign in");
2121        assert!(display.get("access_token").is_none());
2122        assert!(display.get("refresh_token").is_none());
2123    }
2124
2125    #[test]
2126    fn rpc_message_text_joins_content_blocks_with_real_newlines() {
2127        let text = rpc_message_text(&serde_json::json!({
2128            "content": [
2129                {"type": "text", "text": "first"},
2130                {"type": "text", "text": "second"}
2131            ]
2132        }));
2133        assert_eq!(text, "first\nsecond");
2134        assert!(!text.contains("\\n"));
2135    }
2136
2137    #[derive(Clone, Default)]
2138    struct RpcOutput(Arc<Mutex<Vec<u8>>>);
2139
2140    impl Write for RpcOutput {
2141        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2142            self.0.lock().unwrap().extend_from_slice(bytes);
2143            Ok(bytes.len())
2144        }
2145        fn flush(&mut self) -> std::io::Result<()> {
2146            Ok(())
2147        }
2148    }
2149
2150    impl RpcOutput {
2151        fn messages(&self) -> Vec<serde_json::Value> {
2152            String::from_utf8(self.0.lock().unwrap().clone())
2153                .unwrap()
2154                .lines()
2155                .map(|line| serde_json::from_str(line).unwrap())
2156                .collect()
2157        }
2158
2159        async fn wait_for(
2160            &self,
2161            predicate: impl Fn(&serde_json::Value) -> bool,
2162        ) -> serde_json::Value {
2163            tokio::time::timeout(std::time::Duration::from_secs(3), async {
2164                loop {
2165                    if let Some(message) = self.messages().into_iter().find(&predicate) {
2166                        return message;
2167                    }
2168                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
2169                }
2170            })
2171            .await
2172            .expect("RPC message not received")
2173        }
2174    }
2175
2176    async fn rpc_test_harness(
2177        text: &str,
2178        tokens_per_second: f64,
2179    ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
2180        let provider = rpi_ai::providers::faux::FauxProvider::new(
2181            rpi_ai::providers::faux::FauxScript::new()
2182                .with_text(text)
2183                .with_tokens_per_second(tokens_per_second),
2184        );
2185        let options = rpi_harness::types::AgentHarnessOptions {
2186            model: provider.default_model().clone(),
2187            models: vec![provider.clone()],
2188            ..Default::default()
2189        };
2190        (AgentHarness::create(options).await.unwrap(), provider)
2191    }
2192
2193    fn start_test_rpc(
2194        harness: AgentHarness,
2195        ui: rpi_extensions::UiDialogMailbox,
2196    ) -> (
2197        tokio::io::DuplexStream,
2198        RpcOutput,
2199        tokio::task::JoinHandle<i32>,
2200    ) {
2201        let (client, input) = tokio::io::duplex(4096);
2202        let output = RpcOutput::default();
2203        let captured = output.clone();
2204        let task = tokio::spawn(async move {
2205            rpc_with_io(
2206                &harness,
2207                None,
2208                vec![],
2209                ui,
2210                None,
2211                tokio::io::BufReader::new(input),
2212                captured,
2213            )
2214            .await
2215        });
2216        (client, output, task)
2217    }
2218
2219    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2220    async fn rpc_eof_waits_for_accepted_prompt() {
2221        use tokio::io::AsyncWriteExt;
2222        let (harness, _) = rpc_test_harness("completed", 100.0).await;
2223        let ui = rpi_extensions::UiDialogMailbox::new();
2224        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2225        client
2226            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2227            .await
2228            .unwrap();
2229        client.shutdown().await.unwrap();
2230        assert_eq!(
2231            tokio::time::timeout(std::time::Duration::from_secs(3), task)
2232                .await
2233                .unwrap()
2234                .unwrap(),
2235            0
2236        );
2237        let response = output
2238            .messages()
2239            .into_iter()
2240            .find(|m| m["id"] == 1)
2241            .unwrap();
2242        assert_eq!(response["command"], "prompt");
2243        assert_eq!(response["data"]["accepted"], true);
2244        assert!(output.messages().iter().any(|message| {
2245            message["type"] == "agent_settled" && message["outcome"] == "completed"
2246        }));
2247        assert!(!ui.is_attached());
2248    }
2249
2250    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2251    async fn rpc_compaction_settlement_identifies_its_durable_operation() {
2252        let (harness, _) = rpc_test_harness("unused", 0.0).await;
2253        let output = RpcOutput::default();
2254        let code = rpc_with_io(
2255            &harness,
2256            None,
2257            vec![],
2258            rpi_extensions::UiDialogMailbox::new(),
2259            None,
2260            tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
2261            output.clone(),
2262        )
2263        .await;
2264        assert_eq!(code, 0);
2265        let messages = output.messages();
2266        let settled = messages
2267            .iter()
2268            .find(|message| message["type"] == "agent_settled")
2269            .unwrap();
2270        assert_eq!(settled["outcome"], "declined");
2271        let run_id = settled["runId"]
2272            .as_str()
2273            .expect("compaction settlement needs its durable run id");
2274        let records = harness
2275            .session()
2276            .find_records(&Default::default())
2277            .await
2278            .unwrap();
2279        assert!(records.iter().any(|record| matches!(record,
2280            rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2281                if finished.run_id == run_id)));
2282    }
2283
2284    #[tokio::test]
2285    async fn rpc_eof_drains_queued_final_events() {
2286        let harness = AgentHarness::create(Default::default()).await.unwrap();
2287        let (tx, rx) = tokio::sync::broadcast::channel(256);
2288        for _ in 0..100 {
2289            tx.send(AgentEvent::AgentStart).unwrap();
2290        }
2291        tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
2292        let output = RpcOutput::default();
2293        let code = rpc_with_io(
2294            &harness,
2295            Some(rx),
2296            vec![],
2297            rpi_extensions::UiDialogMailbox::new(),
2298            None,
2299            tokio::io::BufReader::new(&b""[..]),
2300            output.clone(),
2301        )
2302        .await;
2303        assert_eq!(code, 0);
2304        let messages = output.messages();
2305        assert_eq!(
2306            messages.len(),
2307            102,
2308            "ready plus every queued event must be flushed"
2309        );
2310        assert_eq!(messages.last().unwrap()["type"], "agent_end");
2311    }
2312
2313    struct BackpressuredOutput {
2314        entered: Arc<std::sync::atomic::AtomicBool>,
2315        release: Arc<std::sync::atomic::AtomicBool>,
2316        output: RpcOutput,
2317    }
2318
2319    impl Write for BackpressuredOutput {
2320        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2321            self.entered
2322                .store(true, std::sync::atomic::Ordering::SeqCst);
2323            while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
2324                std::thread::sleep(std::time::Duration::from_millis(1));
2325            }
2326            self.output.write(bytes)
2327        }
2328        fn flush(&mut self) -> std::io::Result<()> {
2329            Ok(())
2330        }
2331    }
2332
2333    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2334    async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
2335        use std::sync::atomic::{AtomicBool, Ordering};
2336        let release = Arc::new(AtomicBool::new(false));
2337        struct ReleaseOnDrop(Arc<AtomicBool>);
2338        impl Drop for ReleaseOnDrop {
2339            fn drop(&mut self) {
2340                self.0.store(true, Ordering::SeqCst);
2341            }
2342        }
2343        let _release = ReleaseOnDrop(release.clone());
2344        let entered = Arc::new(AtomicBool::new(false));
2345        let output = RpcOutput::default();
2346        let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
2347            entered: entered.clone(),
2348            release: release.clone(),
2349            output: output.clone(),
2350        });
2351        write_line(&writer, &serde_json::json!({"id": 0})).await;
2352        tokio::time::timeout(std::time::Duration::from_secs(2), async {
2353            while !entered.load(Ordering::SeqCst) {
2354                tokio::task::yield_now().await;
2355            }
2356        })
2357        .await
2358        .unwrap();
2359        let producer_writer = writer.clone();
2360        let producer = tokio::spawn(async move {
2361            for id in 1..=100 {
2362                write_line(&producer_writer, &serde_json::json!({"id": id})).await;
2363            }
2364        });
2365        tokio::time::timeout(
2366            std::time::Duration::from_millis(150),
2367            tokio::time::sleep(std::time::Duration::from_millis(20)),
2368        )
2369        .await
2370        .unwrap();
2371        assert!(
2372            !producer.is_finished(),
2373            "the bounded queue must apply backpressure"
2374        );
2375        release.store(true, Ordering::SeqCst);
2376        producer.await.unwrap();
2377        drop(writer);
2378        task.await.unwrap().unwrap();
2379        let messages = output.messages();
2380        assert_eq!(messages.len(), 101);
2381        for (index, message) in messages.iter().enumerate() {
2382            assert_eq!(message["id"], index);
2383        }
2384    }
2385
2386    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2387    async fn rpc_completion_response_follows_final_event_under_backpressure() {
2388        use std::sync::atomic::{AtomicBool, Ordering};
2389        let (harness, _) = rpc_test_harness("completed", 0.0).await;
2390        let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
2391        harness
2392            .set_agent_emitter(Some(Arc::new(emitter)))
2393            .await
2394            .unwrap();
2395        let output = RpcOutput::default();
2396        let release = Arc::new(AtomicBool::new(false));
2397        let release_writer = release.clone();
2398        let release_thread = std::thread::spawn(move || {
2399            std::thread::sleep(std::time::Duration::from_millis(50));
2400            release_writer.store(true, Ordering::SeqCst);
2401        });
2402        let code = rpc_with_io(
2403            &harness,
2404            Some(events),
2405            vec![],
2406            rpi_extensions::UiDialogMailbox::new(),
2407            None,
2408            tokio::io::BufReader::new(
2409                &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
2410            ),
2411            BackpressuredOutput {
2412                entered: Arc::new(AtomicBool::new(false)),
2413                release,
2414                output: output.clone(),
2415            },
2416        )
2417        .await;
2418        release_thread.join().unwrap();
2419        assert_eq!(code, 0);
2420        let messages = output.messages();
2421        let end = messages
2422            .iter()
2423            .position(|message| message["type"] == "agent_end")
2424            .unwrap();
2425        let settled = messages
2426            .iter()
2427            .position(|message| message["type"] == "agent_settled")
2428            .unwrap();
2429        assert!(end < settled);
2430        let run_id = messages[settled]["runId"].as_str().unwrap();
2431        assert!(!run_id.is_empty(), "settlement identifies its durable run");
2432        let records = harness
2433            .session()
2434            .find_records(&Default::default())
2435            .await
2436            .unwrap();
2437        assert!(records.iter().any(|record| matches!(record,
2438            rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2439                if finished.run_id == run_id)));
2440        assert!(messages.iter().any(|message| {
2441            message["type"] == "response" && message["data"]["accepted"] == true
2442        }));
2443        assert!(messages.iter().any(|message| {
2444            message["type"] == "agent_settled" && message["outcome"] == "completed"
2445        }));
2446    }
2447
2448    #[tokio::test]
2449    async fn rpc_output_failure_closes_without_waiting_for_more_input() {
2450        struct BrokenOutput;
2451        impl Write for BrokenOutput {
2452            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
2453                Err(std::io::Error::new(
2454                    std::io::ErrorKind::BrokenPipe,
2455                    "closed client output",
2456                ))
2457            }
2458            fn flush(&mut self) -> std::io::Result<()> {
2459                Ok(())
2460            }
2461        }
2462        let harness = AgentHarness::create(Default::default()).await.unwrap();
2463        let (_client, input) = tokio::io::duplex(4096);
2464        let ui = rpi_extensions::UiDialogMailbox::new();
2465        let code = tokio::time::timeout(
2466            std::time::Duration::from_secs(2),
2467            rpc_with_io(
2468                &harness,
2469                None,
2470                vec![],
2471                ui.clone(),
2472                None,
2473                tokio::io::BufReader::new(input),
2474                BrokenOutput,
2475            ),
2476        )
2477        .await
2478        .unwrap();
2479        assert_eq!(code, 1);
2480        assert!(!ui.is_attached());
2481    }
2482
2483    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2484    async fn rpc_stop_aborts_and_joins_active_prompt() {
2485        use tokio::io::AsyncWriteExt;
2486        let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
2487        let lane = harness.lane("main");
2488        let (mut client, output, task) =
2489            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2490        client
2491            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2492            .await
2493            .unwrap();
2494        tokio::time::timeout(std::time::Duration::from_secs(3), async {
2495            while provider
2496                .state()
2497                .call_count
2498                .load(std::sync::atomic::Ordering::SeqCst)
2499                == 0
2500            {
2501                tokio::task::yield_now().await;
2502            }
2503        })
2504        .await
2505        .unwrap();
2506        client
2507            .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
2508            .await
2509            .unwrap();
2510        assert_eq!(
2511            tokio::time::timeout(std::time::Duration::from_secs(3), task)
2512                .await
2513                .unwrap()
2514                .unwrap(),
2515            0
2516        );
2517        let messages = output.messages();
2518        assert!(messages.iter().any(|message| {
2519            message["type"] == "agent_settled"
2520                && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2521        }));
2522        assert_eq!(
2523            messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
2524            true
2525        );
2526        assert!(
2527            lane.abort().await.is_err(),
2528            "RPC returned with an active lane"
2529        );
2530    }
2531
2532    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2533    async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
2534        use tokio::io::AsyncWriteExt;
2535        let (harness, _) = rpc_test_harness("not completed", 20.0).await;
2536        let lane = harness.lane("main");
2537        let (mut client, output, task) =
2538            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2539        client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
2540        assert_eq!(
2541            tokio::time::timeout(std::time::Duration::from_secs(3), task)
2542                .await
2543                .unwrap()
2544                .unwrap(),
2545            0
2546        );
2547        let messages = output.messages();
2548        assert!(messages.iter().any(|message| {
2549            message["type"] == "agent_settled"
2550                && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2551        }));
2552        assert!(lane.abort().await.is_err());
2553    }
2554
2555    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2556    async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
2557        use tokio::io::AsyncWriteExt;
2558        let (harness, _) =
2559            rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
2560        let ui = rpi_extensions::UiDialogMailbox::new();
2561        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2562        output.wait_for(|m| m["type"] == "ready").await;
2563        client
2564            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2565            .await
2566            .unwrap();
2567        ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
2568            .unwrap();
2569        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2570        client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
2571        output.wait_for(|m| m["id"] == 2).await;
2572        assert_eq!(ui.poll("approval").unwrap()["answer"], true);
2573        output
2574            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
2575            .await;
2576        client.shutdown().await.unwrap();
2577        assert_eq!(
2578            tokio::time::timeout(std::time::Duration::from_secs(3), task)
2579                .await
2580                .unwrap()
2581                .unwrap(),
2582            0
2583        );
2584        assert!(output.messages().iter().any(|message| {
2585            message["type"] == "agent_settled" && message["outcome"] == "completed"
2586        }));
2587    }
2588
2589    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2590    async fn rpc_notifies_client_when_an_approval_is_cancelled() {
2591        use tokio::io::AsyncWriteExt;
2592        let harness = AgentHarness::create(Default::default()).await.unwrap();
2593        let ui = rpi_extensions::UiDialogMailbox::new();
2594        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2595        output.wait_for(|m| m["type"] == "ready").await;
2596        ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
2597            .unwrap();
2598        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2599        ui.cancel_tool_call("c");
2600        output
2601            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
2602            .await;
2603        // The notification must not consume the cancellation meant for the plugin.
2604        assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
2605        client.shutdown().await.unwrap();
2606        assert_eq!(task.await.unwrap(), 0);
2607    }
2608
2609    #[tokio::test]
2610    async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
2611        use crate::remote::protocol::RemoteCommand;
2612        use tokio::io::AsyncWriteExt;
2613        let harness = AgentHarness::create(Default::default()).await.unwrap();
2614        let ui = rpi_extensions::UiDialogMailbox::new();
2615        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2616        output.wait_for(|m| m["type"] == "ready").await;
2617        for (request_id, answer, response_id) in [
2618            (
2619                "answered",
2620                Some(serde_json::json!({"values":["No"],"kind":"selector"})),
2621                5,
2622            ),
2623            ("cancelled", None, 6),
2624        ] {
2625            ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
2626                .unwrap();
2627            output
2628                .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
2629                .await;
2630            let command = RemoteCommand::UiDialogResponse {
2631                request_id: request_id.into(),
2632                cancelled: answer.is_none(),
2633                answer,
2634            }
2635            .with_id(response_id);
2636            client
2637                .write_all(format!("{command}\n").as_bytes())
2638                .await
2639                .unwrap();
2640            let ack = output.wait_for(|m| m["id"] == response_id).await;
2641            assert_eq!(ack["data"]["accepted"], true);
2642            let result = ui.poll(request_id).unwrap();
2643            if request_id == "answered" {
2644                assert_eq!(result["answer"]["values"][0], "No");
2645            } else {
2646                assert_eq!(result["status"], "cancelled");
2647            }
2648        }
2649        client.shutdown().await.unwrap();
2650        assert_eq!(task.await.unwrap(), 0);
2651    }
2652
2653    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
2654        AssistantMessage {
2655            role: rpi_ai::types::AssistantRole,
2656            content: vec![Content::Text(TextContent {
2657                kind: TextContentType,
2658                text: text.into(),
2659                text_signature: None,
2660            })],
2661            api: rpi_ai::Api::AnthropicMessages,
2662            provider: "anthropic".into(),
2663            model: "claude-sonnet-5".into(),
2664            response_model: None,
2665            response_id: None,
2666            usage: Usage::zero(),
2667            stop_reason: stop,
2668            deferred: None,
2669            error_message: None,
2670            raw_stop_reason: None,
2671            end_turn: None,
2672            timestamp: 0,
2673        }
2674    }
2675
2676    #[test]
2677    fn assistant_text_concatenates_text_blocks() {
2678        let m = assistant("hello", StopReason::Stop);
2679        assert_eq!(assistant_text(&m), "hello");
2680    }
2681
2682    #[test]
2683    fn outcome_exit_code_maps_failed_aborted_to_1() {
2684        let failed = HarnessRunOutcome::Failed {
2685            leaf_id: "l".into(),
2686            error: OperationError {
2687                code: "boom".into(),
2688                message: "boom".into(),
2689            },
2690            final_entry_id: None,
2691            final_message: None,
2692        };
2693        assert_eq!(outcome_exit_code(&failed), 1);
2694        let completed = HarnessRunOutcome::Completed {
2695            leaf_id: "l".into(),
2696            final_entry_id: "e".into(),
2697            final_message: assistant("ok", StopReason::Stop),
2698        };
2699        assert_eq!(outcome_exit_code(&completed), 0);
2700    }
2701
2702    #[test]
2703    fn run_end_outcome_str_roundtrip() {
2704        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
2705        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
2706        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
2707    }
2708
2709    #[test]
2710    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
2711        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
2712        assert_eq!(end["type"], "agent_end");
2713        assert_eq!(end["messages"], serde_json::json!([]));
2714
2715        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
2716            tool_call_id: "call-1".into(),
2717            tool_name: "read".into(),
2718            result: AgentToolResult::text("hello"),
2719            is_error: false,
2720        });
2721        assert_eq!(tool["type"], "tool_execution_end");
2722        assert_eq!(tool["result"]["content"][0]["text"], "hello");
2723        assert_eq!(tool["result"]["terminate"], false);
2724
2725        let retry = agent_event_json(&AgentEvent::RetryScheduled {
2726            attempt: 3,
2727            max_retries: 10,
2728            delay_ms: 8_000,
2729            error: "503 service unavailable".into(),
2730        });
2731        assert_eq!(retry["type"], "retry_scheduled");
2732        assert_eq!(retry["attempt"], 3);
2733        assert_eq!(retry["maxRetries"], 10);
2734        assert_eq!(retry["delayMs"], 8_000);
2735        assert_eq!(retry["error"], "503 service unavailable");
2736    }
2737}
2738
2739#[cfg(test)]
2740mod page_window_tests {
2741    use super::page_window;
2742
2743    #[test]
2744    fn tail_is_kept_without_before_id() {
2745        // 12 items, limit 5 -> keep the last 5, more behind.
2746        assert_eq!(page_window(12, 5, None), (7..12, true));
2747        // Fewer items than the limit -> everything, nothing behind.
2748        assert_eq!(page_window(3, 5, None), (0..3, false));
2749    }
2750
2751    #[test]
2752    fn before_id_opens_the_window_immediately_before_it() {
2753        // Page back from index 10 with limit 4 -> [6,10), more behind.
2754        assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
2755        // Same, but reaching the very start -> no more behind.
2756        assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
2757    }
2758
2759    #[test]
2760    fn limit_larger_than_the_branch_never_underflows() {
2761        assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
2762        assert_eq!(page_window(5, 100, None), (0..5, false));
2763    }
2764}