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