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