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