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