Skip to main content

rpi_cli/
modes.rs

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