Skip to main content

rpi_cli/
modes.rs

1//! Output modes. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/modes/{print-mode,json-event,rpc-mode}.ts` — the
3//! three run shapes a harness-backed CLI needs:
4//!
5//! - [`print`] — single-shot: send the prompt(s), print the final assistant
6//!   text (or the error) to stdout, exit. Mirrors TS `runPrintMode` (text mode).
7//! - [`json`] — single-shot streaming: emit each harness event as a JSON line
8//!   on stdout, then the final outcome. Mirrors TS `runPrintMode`
9//!   (`mode === "json"`) + [`json_event::toJsonEvent`].
10//! - [`interactive`] — a minimal line-oriented REPL: read prompts from stdin,
11//!   run each, print the assistant text, loop until EOF / `/exit`. v1 does NOT
12//!   port the TS `InteractiveMode` TUI (`modes/interactive/*` — a full terminal
13//!   UI with Ink/React components); this is a deliberately minimal replacement,
14//!   documented in the initial port notes (retired).
15//!
16//! All three drive the same `AgentHarness` via `AgentLane::prompt_text`.
17
18use std::cmp::Reverse;
19use std::collections::HashSet;
20use std::io::{BufRead, IsTerminal, Write};
21use std::sync::{Arc, Mutex};
22
23use rpi_agent::events::AgentEvent;
24use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason, UserMessage};
25use rpi_harness::agent_harness::{
26    AgentHarness, AgentLane, CompactionOutcome, CompactionResult, HarnessRunOutcome,
27};
28use rpi_harness::events::{HarnessEvent, RunEndOutcome};
29use rpi_harness::session::session::SessionEntryCommit;
30use rpi_harness::session::types::{
31    BranchBounds, Entry, EntryCursor, EntryOrder, EntryQuery, LaneRecord, RecordQuery,
32};
33
34use crate::args::Args;
35use crate::rpc::protocol as rpc_protocol;
36use crate::rpc::writer::{start_rpc_writer, write_agent_event, write_line, RpcWriter};
37
38/// Extract the concatenated text content from an assistant message. Mirrors the
39/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
40pub fn assistant_text(msg: &AssistantMessage) -> String {
41    msg.content
42        .iter()
43        .filter_map(|c| match c {
44            Content::Text(t) => Some(t.text.clone()),
45            _ => None,
46        })
47        .collect()
48}
49
50/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
51/// ⇒ exit 1; everything else ⇒ 0.
52pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
53    match outcome {
54        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
55        _ => 0,
56    }
57}
58
59/// `print` mode: send the initial message (prompt text + inline `@file`
60/// expansions), then any follow-up messages, print the final assistant text,
61/// return the exit code. Mirrors TS `runPrintMode` (text).
62pub async fn print(
63    harness: &AgentHarness,
64    args: &Args,
65    initial: Option<String>,
66    extra_messages: &[String],
67    initial_images: Vec<ImageContent>,
68) -> i32 {
69    let lane: Arc<dyn AgentLane> = harness.lane("main");
70
71    let mut last_exit = 0;
72    let mut persistence_failed = false;
73    let mut last_msg: Option<AssistantMessage> = None;
74
75    // The initial prompt (and its `@file` attachments) go in one user message;
76    // extra positionals are separate prompts (mirrors the TS loop).
77    let mut prompts: Vec<String> = Vec::new();
78    if let Some(init) = initial {
79        prompts.push(init);
80    }
81    for m in extra_messages {
82        prompts.push(m.clone());
83    }
84
85    if prompts.is_empty() {
86        // Nothing to do — print mode with no prompt is a no-op success.
87        return 0;
88    }
89
90    let mut images = initial_images;
91    for prompt in prompts {
92        let outcome = lane.prompt_text(&prompt, std::mem::take(&mut images)).await;
93        // Print mode has no transcript to report into, so a write failure goes to
94        // stderr. Silence here is what let a conversation stop being saved while
95        // the run looked fine.
96        for failure in lane.take_write_failures() {
97            persistence_failed |= args.experimental_native_store.is_some();
98            eprintln!(
99                "session not saved: could not persist {} ({})",
100                failure.what, failure.message
101            );
102        }
103        match outcome {
104            Ok(result) => {
105                last_exit = outcome_exit_code(&result.outcome);
106                match &result.outcome {
107                    HarnessRunOutcome::Completed { final_message, .. }
108                    | HarnessRunOutcome::Aborted { final_message, .. } => {
109                        last_msg = Some(final_message.clone());
110                    }
111                    HarnessRunOutcome::Failed {
112                        error,
113                        final_message,
114                        ..
115                    } => {
116                        if let Some(m) = final_message {
117                            if m.stop_reason == StopReason::Error {
118                                if let Some(em) = &m.error_message {
119                                    eprintln!("{em}");
120                                }
121                            }
122                        }
123                        if error.code == "SessionStorage" {
124                            eprintln!("{}", session_storage_notice(&error.message));
125                        } else {
126                            eprintln!("run failed: {error:?}");
127                        }
128                    }
129                    HarnessRunOutcome::Suspended { .. } => {
130                        eprintln!("run suspended (deferred) — resume is not supported in v1");
131                        last_exit = 1;
132                    }
133                }
134            }
135            Err(e) => {
136                eprintln!("prompt rejected: {e}");
137                return 1;
138            }
139        }
140    }
141
142    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
143    if let Some(m) = &last_msg {
144        match m.stop_reason {
145            StopReason::Error => {
146                if let Some(em) = &m.error_message {
147                    eprintln!("{em}");
148                }
149                last_exit = 1;
150            }
151            StopReason::Aborted => {
152                eprintln!("request aborted");
153                last_exit = 1;
154            }
155            _ => {
156                let text = assistant_text(m);
157                let mut out = std::io::stdout();
158                let _ = out.write_all(text.as_bytes());
159                if !text.ends_with('\n') {
160                    let _ = out.write_all(b"\n");
161                }
162                let _ = out.flush();
163            }
164        }
165    }
166
167    if persistence_failed {
168        1
169    } else {
170        last_exit
171    }
172}
173
174/// `json` mode: emit each harness event as a JSON line on stdout, run the
175/// prompts, then emit a terminal `result` line carrying the outcome + final
176/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
177pub async fn json(
178    harness: &AgentHarness,
179    args: &Args,
180    initial: Option<String>,
181    extra_messages: &[String],
182    initial_images: Vec<ImageContent>,
183    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
184) -> i32 {
185    let lane: Arc<dyn AgentLane> = harness.lane("main");
186    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
187    let collected_for_watch = collected.clone();
188
189    // A watch captures every event (RunStart fires inline during prompt_text,
190    // before a post-call listener could attach — same reason as the M5g test).
191    let mut watch = harness.events().watch(|| ());
192    watch.start(Arc::new(move |event: &HarnessEvent| {
193        // Emit each event live as JSON, and also buffer for the final summary.
194        emit_json_event(event);
195        collected_for_watch.lock().unwrap().push(event.clone());
196    }));
197    // Keep the watch alive for the whole run. Leaking is acceptable for a
198    // single-shot CLI process (the bus outlives this scope anyway).
199    std::mem::forget(watch);
200
201    // The harness bus carries run lifecycle events; the agent receiver carries
202    // the native fine-grained stream (turns, message deltas, and tools).
203    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
204    let agent_event_task = agent_events.take().map(|mut rx| {
205        tokio::spawn(async move {
206            let done_tx = agent_done_tx;
207            loop {
208                match rx.recv().await {
209                    Ok(event) => {
210                        let terminal = event.is_terminal();
211                        emit_agent_event(&event);
212                        if terminal {
213                            let _ = done_tx.send(());
214                        }
215                    }
216                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
217                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
218                }
219            }
220        })
221    });
222
223    let mut prompts: Vec<String> = Vec::new();
224    if let Some(init) = initial {
225        prompts.push(init);
226    }
227    for m in extra_messages {
228        prompts.push(m.clone());
229    }
230
231    let mut last_exit = 0;
232    let mut persistence_failed = false;
233    let mut final_outcome: Option<HarnessRunOutcome> = None;
234
235    let mut images = initial_images;
236    for prompt in prompts {
237        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
238            Ok(result) => {
239                // The harness resolves after its run outcome, while the
240                // broadcast listener may still be scheduling the terminal
241                // AgentEnd line. Wait briefly so JSON consumers see the full
242                // lifecycle before the final result summary. The timeout is
243                // deliberately bounded for custom/older harness emitters.
244                let _ = tokio::time::timeout(
245                    std::time::Duration::from_millis(250),
246                    agent_done_rx.recv(),
247                )
248                .await;
249                last_exit = outcome_exit_code(&result.outcome);
250                final_outcome = Some(result.outcome);
251            }
252            Err(e) => {
253                // Emit a structured error line + exit.
254                let line = serde_json::json!({
255                    "type": "error",
256                    "error": e.to_string(),
257                });
258                println!("{line}");
259                if args.experimental_native_store.is_some() {
260                    for failure in lane.take_write_failures() {
261                        persistence_failed = true;
262                        println!(
263                            "{}",
264                            serde_json::json!({"type":"error","kind":"session_write_failed","error":failure.message,"what":failure.what})
265                        );
266                    }
267                    last_exit = 1;
268                    break;
269                }
270                if let Some(task) = agent_event_task {
271                    task.abort();
272                }
273                return 1;
274            }
275        }
276        if args.experimental_native_store.is_some() {
277            for failure in lane.take_write_failures() {
278                persistence_failed = true;
279                println!(
280                    "{}",
281                    serde_json::json!({"type":"error","kind":"session_write_failed","error":failure.message,"what":failure.what})
282                );
283            }
284        }
285    }
286
287    // Terminal result summary.
288    let (outcome_str, final_text) = match final_outcome {
289        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
290            ("completed", Some(assistant_text(&final_message)))
291        }
292        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
293            ("aborted", Some(assistant_text(&final_message)))
294        }
295        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
296            let t = final_message.as_ref().map(assistant_text);
297            ("failed", t)
298        }
299        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
300        None if last_exit != 0 => ("failed", None),
301        None => ("idle", None),
302    };
303    let mut result_line = serde_json::json!({
304        "type": "result",
305        "outcome": if persistence_failed{"failed"}else{outcome_str},
306        "finalText": final_text,
307    });
308    if persistence_failed {
309        result_line["persistenceFailed"] = true.into();
310        last_exit = 1;
311    }
312    println!("{result_line}");
313    if let Some(task) = agent_event_task {
314        task.abort();
315    }
316    last_exit
317}
318
319fn emit_agent_event(event: &AgentEvent) {
320    println!("{}", agent_event_json(event));
321}
322
323/// `rpc` mode: a headless JSONL command loop (the server-side agent).
324///
325/// Reads one JSON command per line from stdin (`{id?, type, ...}`), drives the
326/// `main` lane, and writes one JSON object per line to stdout:
327/// - fine-grained `AgentEvent`s via [`agent_event_json`] (each carries `type`),
328/// - a `{"type":"response","id":<id>,"status":"ok"|"error",...}` per command.
329///
330/// Commands: `prompt`, `resume_pending`, and `compact` (drive the lane),
331/// `abort`, `get_state`, `set_model`,
332/// `set_thinking_level`, `set_active_tools`, `ping`, `stop`. `stop` (or stdin
333/// EOF) ends the loop. This is the process `rpi-server` spawns and that
334/// `rpi --connect` talks to. Mirrors TS `runRpcMode` / `modes/rpc`.
335///
336/// The extension `UiDialog` mailbox is bridged over the same channel: the host
337/// emits `{"type":"ui_dialog_request","requestId","toolCallId","ui"}`, and the
338/// client answers with `{"type":"ui_dialog_response","requestId","answer"}` (or
339/// `"cancelled":true`). A `prompt` runs in its own task so this loop keeps
340/// reading — that is what lets `abort` and dialog answers arrive mid-run.
341pub async fn rpc(
342    harness: &AgentHarness,
343    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
344    model_catalog: Vec<rpi_ai::Model>,
345    ui_dialog: rpi_extensions::UiDialogMailbox,
346    oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
347    message_updates: crate::args::RpcMessageUpdates,
348) -> i32 {
349    rpc_with_io(
350        harness,
351        agent_events.take(),
352        model_catalog,
353        ui_dialog,
354        oauth_snapshot,
355        message_updates,
356        tokio::io::BufReader::new(tokio::io::stdin()),
357        std::io::stdout(),
358    )
359    .await
360}
361
362/// Opt-in, payload-free stage markers. An unmatched begin identifies an
363/// unfinished await, not its cause. Enqueue completion is NOT pipe delivery.
364struct RpcPhaseTiming {
365    phase: &'static str,
366    started: Option<std::time::Instant>,
367}
368
369impl RpcPhaseTiming {
370    fn start(enabled: bool, phase: &'static str) -> Self {
371        let started = enabled.then(std::time::Instant::now);
372        if enabled {
373            eprintln!("RPI_RPC_DIAGNOSTICS phase={phase} event=begin");
374        }
375        Self { phase, started }
376    }
377
378    fn finish(self) {
379        if let Some(started) = self.started {
380            eprintln!(
381                "RPI_RPC_DIAGNOSTICS phase={} event=end elapsed_ns={}",
382                self.phase,
383                started.elapsed().as_nanos()
384            );
385        }
386    }
387}
388
389async fn rpc_with_io<R, W>(
390    harness: &AgentHarness,
391    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
392    model_catalog: Vec<rpi_ai::Model>,
393    ui_dialog: rpi_extensions::UiDialogMailbox,
394    oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
395    message_updates: crate::args::RpcMessageUpdates,
396    mut input: R,
397    output: W,
398) -> i32
399where
400    R: tokio::io::AsyncBufRead + Unpin,
401    W: Write + Send + 'static,
402{
403    let phase_diagnostics =
404        std::env::var_os("RPI_RPC_DIAGNOSTICS").is_some_and(|value| value != "0");
405    let setup_timing = RpcPhaseTiming::start(phase_diagnostics, "setup_to_ready_enqueue");
406    let mut command_tasks: Vec<RetainedRpcTask<()>> = Vec::new();
407    let lane: Arc<dyn AgentLane> = harness.lane("main");
408    // One blocking writer owns stdout. Producers only enqueue bounded messages,
409    // so pipe backpressure never blocks a Tokio worker on IO or a std mutex.
410    let (stdout, writer_task, writer_failed) = start_rpc_writer(output, message_updates);
411    let writer_task = RetainedRpcTask::new(writer_task, writer_failed.clone());
412    let pump_stop = tokio_util::sync::CancellationToken::new();
413    let external_tools =
414        crate::rpc::external_tools::ExternalTools::new(stdout.clone(), Arc::downgrade(&lane));
415    let mut external_names = Vec::new();
416    let file_io_router = crate::session::file_io_router(harness);
417    let mut owner = RpcOwner {
418        harness,
419        file_io: None,
420        backend: external_tools.clone(),
421        run_cancel: tokio_util::sync::CancellationToken::new(),
422        pump_stop: pump_stop.clone(),
423        failed: writer_failed.clone(),
424        mailbox: ui_dialog.clone(),
425        armed: true,
426    };
427
428    // Publish generic commit metadata after successful Session appends. The
429    // event is intentionally not a durable-state payload: consumers reread
430    // the canonical branch and use this only as an invalidation signal.
431    let (commit_tx, mut commit_rx) = tokio::sync::broadcast::channel::<SessionEntryCommit>(256);
432    harness
433        .session()
434        .set_entry_commit_publisher(Some(commit_tx.clone()));
435    let commit_task = {
436        let out = stdout.clone();
437        tokio::spawn(async move {
438            while let Some(event) =
439                crate::rpc::tool_program_queries::commit_stream_event(commit_rx.recv().await)
440            {
441                write_line(&out, &event).await;
442            }
443        })
444    };
445
446    let commit_task = RetainedRpcTask::new(commit_task, pump_stop.clone());
447
448    // A completion response is queued only after its final events, so a
449    // subsequent prompt cannot overtake the previous run's agent_end.
450    let (event_flush_tx, mut event_flush_rx) =
451        tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
452    // Stream fine-grained AgentEvents as JSONL until the receiver closes.
453    let event_task = agent_events.take().map(|mut rx| {
454        let out = stdout.clone();
455        let stop = pump_stop.clone();
456        tokio::spawn(async move {
457            loop {
458                let received = tokio::select! {
459                    _ = stop.cancelled() => {
460                        drain_rpc_events(&out, &mut rx).await;
461                        break;
462                    }
463                    Some(done) = event_flush_rx.recv() => {
464                        drain_rpc_events(&out, &mut rx).await;
465                        let _ = done.send(());
466                        continue;
467                    }
468                    event = rx.recv() => event,
469                };
470                match received {
471                    Ok(event) => write_agent_event(&out, &event).await,
472                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
473                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
474                }
475            }
476        })
477    });
478
479    let event_task = event_task.map(|task| RetainedRpcTask::new(task, pump_stop.clone()));
480    let event_flush = event_task.as_ref().map(|_| event_flush_tx);
481
482    // Bridge the extension `UiDialog` mailbox over the rpc channel so a
483    // permission-gate extension can ask the client a question mid-run: we emit
484    // `{"type":"ui_dialog_request",…}` and answer it from a
485    // `{"type":"ui_dialog_response",…}` on stdin. Without this a headless run
486    // leaves the mailbox detached and every `UiDialog` call fails.
487    ui_dialog.attach();
488    let dialog_task = {
489        let mailbox = ui_dialog.clone();
490        let out = stdout.clone();
491        let stop = pump_stop.clone();
492        tokio::spawn(async move {
493            let mut shown = std::collections::HashSet::new();
494            while !stop.is_cancelled() {
495                // This **is** the external UI in `--remote` mode, so it claims
496                // ownership: the local TUI must not also take from the queue.
497                mailbox.set_external_takeover(true);
498                // Skip plugin-reserved requests: a remote client takes those
499                // through `RelayUiDialog`, and consuming them here would leave
500                // the question unanswered on both ends.
501                while let Some(request) =
502                    mailbox.take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
503                {
504                    let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
505                        request_id: request.request_id.clone(),
506                        tool_call_id: request.tool_call_id,
507                        ui: request.ui,
508                    };
509                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
510                    shown.insert(request.request_id);
511                }
512                let closed: Vec<String> = shown
513                    .iter()
514                    .filter(|id| !mailbox.is_pending(id))
515                    .cloned()
516                    .collect();
517                for request_id in closed {
518                    shown.remove(&request_id);
519                    let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
520                    write_line(&out, &serde_json::to_value(event).unwrap()).await;
521                }
522                tokio::select! {
523                    _ = stop.cancelled() => break,
524                    _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
525                }
526            }
527        })
528    };
529
530    let dialog_task = RetainedRpcTask::new(dialog_task, pump_stop.clone());
531
532    // A `prompt` runs in a spawned task so this loop keeps reading stdin while
533    // the run is active — otherwise `abort` and `ui_dialog_response` could not
534    // be delivered mid-run. Only one prompt may be in flight at a time.
535    let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
536    let mut run_task: Option<OwnedRpcRun> = None;
537    let mut run_cancel = tokio_util::sync::CancellationToken::new();
538    let mut cancel_on_exit = false;
539
540    let binding = match rpi_harness::session::SessionValues::load(harness.session()).await {
541        Ok(values) => crate::resource_selection::binding_state(&values),
542        Err(error) => {
543            eprintln!("resource binding read failed: {error}");
544            return 1;
545        }
546    };
547    let mut ready = rpc_protocol::ready(message_updates);
548    ready["resourceState"] = serde_json::json!({
549        "version": 1, "enabled": harness.has_exact_resources(), "command": "get_resource_state",
550        "maxSnapshotBytes": crate::resource_selection::MAX_STATE_BYTES,
551        "changes": "restart-required", "setActiveTools": if harness.has_exact_resources() { "unsupported" } else { "legacy" },
552        "configureExternalTools": if harness.has_exact_resources() { "unsupported" } else { "supported" },
553        "automaticPersistenceRecovery": false, "hotApply": false,
554        "packageScopes": ["project", "global"], "defaultPackageScope": "project",
555        "transition": crate::resource_selection::transition_capability(),
556        "binding": binding
557    });
558    if harness.has_exact_resources() {
559        if let Ok(state) = crate::resource_selection::get_resource_state(harness).await {
560            for key in [
561                "hostId",
562                "host",
563                "sessionId",
564                "selection",
565                "binding",
566                "transition",
567            ] {
568                ready["resourceState"][key] = state[key].clone();
569            }
570        } else {
571            ready["resourceState"]["enabled"] = serde_json::json!(false);
572        }
573    }
574    write_line(&stdout, &ready).await;
575    setup_timing.finish();
576
577    loop {
578        let incoming = tokio::select! {
579            _ = writer_failed.cancelled() => {
580                cancel_on_exit = true;
581                break;
582            }
583            line = crate::rpc::framing::read_frame(&mut input) => line,
584        };
585        let line = match incoming {
586            Ok(Some(line)) => line,
587            Ok(None) => break, // EOF
588            Err(error) => {
589                eprintln!("[rpi] rpc stdin error: {error}");
590                run_cancel.cancel();
591                let _ = lane.abort().await;
592                external_tools.shutdown();
593                writer_failed.cancel();
594                cancel_on_exit = true;
595                break;
596            }
597        };
598        let line = match std::str::from_utf8(&line) {
599            Ok(line) => line.trim(),
600            Err(_) => {
601                run_cancel.cancel();
602                let _ = lane.abort().await;
603                external_tools.shutdown();
604                writer_failed.cancel();
605                cancel_on_exit = true;
606                break;
607            }
608        };
609        if line.is_empty() {
610            continue;
611        }
612        let msg: serde_json::Value = match serde_json::from_str(line) {
613            Ok(value) => value,
614            Err(error) => {
615                write_line(
616                    &stdout,
617                    &rpc_protocol::response(
618                        None,
619                        "invalid_request",
620                        Err(format!("invalid json: {error}")),
621                    ),
622                )
623                .await;
624                continue;
625            }
626        };
627        let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
628        let kind = msg
629            .get("type")
630            .and_then(|v| v.as_str())
631            .unwrap_or("")
632            .to_string();
633
634        if kind == "get_resource_state" && serde_json::to_vec(&id).map_or(true, |v| v.len() > 4096)
635        {
636            write_line(
637                &stdout,
638                &rpc_protocol::response(
639                    None,
640                    &kind,
641                    Err("resource state request id exceeds 4096 bytes".into()),
642                ),
643            )
644            .await;
645            continue;
646        }
647        if kind != "prompt"
648            && rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
649            && serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
650        {
651            write_line(
652                &stdout,
653                &rpc_protocol::response(
654                    Some(id.clone()),
655                    &kind,
656                    Err("invalid command payload".to_string()),
657                ),
658            )
659            .await;
660            continue;
661        }
662
663        match kind.as_str() {
664            "ui_dialog_response" => {
665                let request_id = msg
666                    .get("requestId")
667                    .and_then(|v| v.as_str())
668                    .unwrap_or("")
669                    .to_string();
670                let result = if msg
671                    .get("cancelled")
672                    .and_then(|v| v.as_bool())
673                    .unwrap_or(false)
674                {
675                    ui_dialog.cancel(&request_id)
676                } else {
677                    let answer = msg
678                        .get("answer")
679                        .cloned()
680                        .unwrap_or(serde_json::Value::Null);
681                    ui_dialog.respond(&request_id, answer)
682                };
683                if msg.get("id").is_some() {
684                    let response = match result {
685                        Ok(()) => rpc_protocol::response(
686                            Some(id),
687                            "ui_dialog_response",
688                            Ok(Some(
689                                serde_json::json!({"accepted":true,"requestId":request_id}),
690                            )),
691                        ),
692                        Err(error) => {
693                            rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
694                        }
695                    };
696                    write_line(&stdout, &response).await;
697                } else if let Err(error) = result {
698                    eprintln!("[rpi] ui_dialog_response: {error}");
699                }
700                continue;
701            }
702            "execute_command" => {
703                command_tasks.retain(|task| !task.is_finished());
704                let snapshot = oauth_snapshot.clone();
705                let request = msg.clone();
706                let out = stdout.clone();
707                command_tasks.push(RetainedRpcTask::new(
708                    tokio::spawn(async move {
709                        let result = tokio::task::spawn_blocking(move || {
710                            rpc_execute_command(snapshot.as_deref(), &request)
711                        })
712                        .await
713                        .unwrap_or_else(|_| Err("command task failed".into()));
714                        write_line(
715                            &out,
716                            &rpc_protocol::response(Some(id), "execute_command", result.map(Some)),
717                        )
718                        .await;
719                    }),
720                    writer_failed.clone(),
721                ));
722                continue;
723            }
724            "stop" => {
725                cancel_on_exit = true;
726                run_cancel.cancel();
727                let _ = lane.abort().await;
728                external_tools.shutdown();
729                write_line(
730                    &stdout,
731                    &rpc_protocol::response(
732                        Some(id.clone()),
733                        "stop",
734                        Ok(Some(serde_json::json!({"stopped": true}))),
735                    ),
736                )
737                .await;
738                break;
739            }
740            "prompt" | "resume_pending" | "compact" => {
741                let resume = kind == "resume_pending";
742                let compact = kind == "compact";
743                if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
744                    write_line(
745                        &stdout,
746                        &rpc_protocol::response(
747                            Some(id.clone()),
748                            &kind,
749                            Err("a run is already active".to_string()),
750                        ),
751                    )
752                    .await;
753                    continue;
754                }
755                if let Some(task) = run_task.take() {
756                    let _ = task.await;
757                }
758                run_cancel = tokio_util::sync::CancellationToken::new();
759                owner.run_cancel = run_cancel.clone();
760                let cancel_task = run_cancel.clone();
761                // Resuming spends the tokens the interrupted run never did, so it
762                // only proceeds when recovery actually left a pending run.
763                if resume && !lane.has_pending_resume().await {
764                    run_active.store(false, std::sync::atomic::Ordering::Release);
765                    write_line(
766                        &stdout,
767                        &rpc_protocol::response(
768                            Some(id),
769                            "resume_pending",
770                            Ok(Some(serde_json::json!({"resumed": false}))),
771                        ),
772                    )
773                    .await;
774                    continue;
775                }
776                let lane_task = Arc::clone(&lane);
777                let out_task = stdout.clone();
778                let active_task = Arc::clone(&run_active);
779                let mailbox_task = ui_dialog.clone();
780                let flush_events = event_flush.clone();
781                let instructions = msg
782                    .get("customInstructions")
783                    .and_then(|v| v.as_str())
784                    .map(str::to_owned);
785                // The run slot includes validation. Reader/control traffic keeps
786                // flowing while the single bounded blocking worker decodes.
787                // Rejections receive ONE failed prompt response, no acceptance,
788                // no model call, no user persistence, and no agent_settled.
789                run_task = Some(OwnedRpcRun::new(
790                    tokio::spawn(async move {
791                        #[cfg(test)]
792                        let _finished =
793                            crate::rpc::prompt_content::validation_test_hook::for_command(&msg)
794                                .map(crate::rpc::prompt_content::validation_test_hook::RunFinished);
795                        let validation_timing =
796                            RpcPhaseTiming::start(phase_diagnostics, "validation");
797                        let content = if resume || compact {
798                            Ok(crate::rpc::prompt_content::ParsedPrompt::Legacy(
799                                String::new(),
800                            ))
801                        } else {
802                            rpc_validate_prompt(&lane_task, msg, &cancel_task).await
803                        };
804                        validation_timing.finish();
805                        let content = match content {
806                            Ok(content) => content,
807                            Err(error) => {
808                                write_line(
809                                    &out_task,
810                                    &rpc_protocol::response(Some(id), &kind, Err(error)),
811                                )
812                                .await;
813                                active_task.store(false, std::sync::atomic::Ordering::Release);
814                                return;
815                            }
816                        };
817                        write_line(
818                            &out_task,
819                            &rpc_protocol::response(
820                                Some(id),
821                                &kind,
822                                Ok(Some(serde_json::json!({"accepted": true}))),
823                            ),
824                        )
825                        .await;
826                        let outcome_cancel = cancel_task.clone();
827                        let execute_timing = RpcPhaseTiming::start(phase_diagnostics, "execute");
828                        let result = if compact {
829                            rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
830                        } else if resume {
831                            rpc_resume_cancellable(&lane_task, cancel_task).await
832                        } else {
833                            rpc_prompt_cancellable(&lane_task, content, cancel_task).await
834                        };
835                        execute_timing.finish();
836                        let flush_timing = RpcPhaseTiming::start(phase_diagnostics, "event_flush");
837                        if let Some(flush) = flush_events {
838                            let (done, drained) = tokio::sync::oneshot::channel();
839                            if flush.send(done).is_ok() {
840                                let _ = drained.await;
841                            }
842                        }
843                        flush_timing.finish();
844                        let (outcome, aborted, error) = match &result {
845                            Ok(value) => {
846                                let outcome = value
847                                    .get("outcome")
848                                    .and_then(|v| v.as_str())
849                                    .unwrap_or("completed")
850                                    .to_string();
851                                let error = value
852                                    .get("error")
853                                    .and_then(|v| v.as_str())
854                                    .map(str::to_owned);
855                                (outcome.clone(), outcome == "aborted", error)
856                            }
857                            Err(error) if outcome_cancel.is_cancelled() => {
858                                ("aborted".to_string(), true, Some(error.clone()))
859                            }
860                            Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
861                        };
862                        // Recovery may leave a continuable run behind an abort, so the
863                        // settlement carries what the client needs to offer "continue".
864                        let resume_timing =
865                            RpcPhaseTiming::start(phase_diagnostics, "pending_resume");
866                        let pending_resume = lane_task.has_pending_resume().await;
867                        resume_timing.finish();
868                        let summary = result
869                            .as_ref()
870                            .ok()
871                            .and_then(|value| value.get("summary"))
872                            .cloned();
873                        let settled = serde_json::json!({
874                            "type": "agent_settled",
875                            "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
876                            "outcome": outcome,
877                            "aborted": aborted,
878                            "error": error,
879                            "summary": summary,
880                            "pendingResume": pending_resume,
881                            // Present when this settlement also aborted a run:
882                            // the queue the abort reclaimed, so nothing staged
883                            // is silently swallowed.
884                            "reclaimed": result
885                                .as_ref()
886                                .ok()
887                                .and_then(|value| value.get("reclaimed"))
888                                .cloned(),
889                        });
890                        let enqueue_timing =
891                            RpcPhaseTiming::start(phase_diagnostics, "settled_enqueue");
892                        write_line(&out_task, &settled).await;
893                        enqueue_timing.finish();
894                        mailbox_task.cancel_all();
895                        active_task.store(false, std::sync::atomic::Ordering::Release);
896                    }),
897                    run_cancel.clone(),
898                ));
899                continue;
900            }
901            _ => {}
902        }
903
904        let result = match kind.as_str() {
905            "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
906                // Keep wrapper cancellation for pre-admission aborts, but signal
907                // an admitted harness run before retiring external broker waiters.
908                run_cancel.cancel();
909                let result = lane.abort().await;
910                external_tools.abort();
911                // Hand the reclaimed queue back: the caller is a remote UI with
912                // an editor to restore into. Discarding it would silently swallow
913                // messages the user staged while the run was in flight.
914                abort_result_reply(result)
915            }
916            "abort" => {
917                let result = lane.abort().await;
918                external_tools.abort();
919                abort_result_reply(result)
920            }
921            "configure_external_tools" => {
922                rpc_configure_external_tools(
923                    &lane,
924                    harness,
925                    run_active.load(std::sync::atomic::Ordering::Acquire),
926                    &external_tools,
927                    &mut external_names,
928                    &msg,
929                )
930                .await
931            }
932            "external_tool_response" => external_tools
933                .respond(&msg)
934                .map(|accepted| serde_json::json!({"accepted": accepted})),
935            "configure_file_io" => {
936                rpc_configure_file_io(
937                    &lane,
938                    run_active.load(std::sync::atomic::Ordering::Acquire),
939                    file_io_router.as_ref(),
940                    &mut owner.file_io,
941                    &stdout,
942                    &msg,
943                )
944                .await
945            }
946            // Replies are never run-gated; the tool is waiting on this reader.
947            "file_io_response" => {
948                // Correlation belongs to the dispatcher, not the strict broker
949                // envelope. Move the owned frame and strip ONLY id: arbitrary
950                // extra fields must still fail the broker's exact-shape check.
951                let mut reply = msg;
952                if let Some(object) = reply.as_object_mut() {
953                    object.remove("id");
954                }
955                Ok(serde_json::json!({"accepted": owner.file_io.as_ref()
956                    .is_some_and(|client| client.resolve_response(&reply))}))
957            }
958            "get_resource_state" => {
959                if msg.as_object().is_none_or(|o| {
960                    o.keys()
961                        .any(|k| !["type", "id", "version"].contains(&k.as_str()))
962                }) || msg.get("version").is_some_and(|v| v.as_u64() != Some(1))
963                {
964                    Err("unsupported resource state request/schema (version 1 only)".into())
965                } else {
966                    crate::resource_selection::get_resource_state(harness).await
967                }
968            }
969            "get_state" => Ok(rpc_get_state(&lane, harness).await),
970            "get_commands" => Ok(rpc_get_commands(oauth_snapshot.as_deref())),
971            "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
972            "get_tool_program_branch" => {
973                crate::rpc::tool_program_queries::branch(harness, &msg).await
974            }
975            "get_message_summaries" => crate::rpc::message_queries::summaries(harness, &msg).await,
976            "get_message_window" => crate::rpc::message_queries::window(harness, &msg).await,
977            "get_tool_program_children" => {
978                crate::rpc::tool_program_queries::children(harness, &msg).await
979            }
980            "get_tool_program_child_phases" => {
981                crate::rpc::tool_program_queries::phases(harness, &msg).await
982            }
983            "get_tool_program_child_body" => {
984                crate::rpc::tool_program_queries::body(harness, &msg).await
985            }
986            "get_history" => rpc_get_history(&lane, harness, &msg).await,
987            "get_session_branch" => rpc_get_session_branch(harness, &msg).await,
988            "append_entry" => rpc_append_entry(harness, &msg).await,
989            "get_stats" => rpc_get_stats(&lane, harness).await,
990            "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
991            "queue_message" => rpc_queue_message(&lane, &msg).await,
992            "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
993            "clear_queue" => rpc_clear_queue(&lane).await,
994            "navigate" => rpc_navigate(&lane, &msg).await,
995            "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
996            "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
997            "get_provider_models" => rpc_get_provider_models(&msg).await,
998            "test_provider_config" => rpc_test_provider_config(&msg).await,
999            "oauth" => rpc_oauth(&oauth_snapshot, &msg),
1000            "set_api_key" => rpc_set_api_key(&msg).await,
1001            "set_provider_config" => rpc_set_provider_config(&msg),
1002            "update_provider_config" => rpc_update_provider_config(&msg),
1003            "delete_provider_config" => rpc_delete_provider_config(&msg),
1004            "set_model" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
1005                Err("cannot set model while a run or prompt validation is active".into())
1006            }
1007            "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
1008            "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
1009            "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
1010            "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
1011            "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
1012            "ping" => Ok(serde_json::json!({"pong": true})),
1013            other => Err(format!("unknown command type: {other}")),
1014        };
1015
1016        let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
1017        write_line(&stdout, &response).await;
1018    }
1019
1020    // EOF closes the input half of the protocol, not an accepted prompt.
1021    // There can be no more dialog answers, so release approvals, then await the
1022    // run before SessionShutdown can unload plugins or the runtime exits.
1023    ui_dialog.detach();
1024    if cancel_on_exit {
1025        run_cancel.cancel();
1026        let _ = lane.abort().await;
1027    }
1028    // EOF makes future external replies impossible. Disable the backend before
1029    // joining, without cancelling an otherwise healthy model-only run.
1030    external_tools.shutdown();
1031    if let Some(client) = owner.file_io.take() {
1032        client.close();
1033    }
1034    for task in command_tasks {
1035        let _ = task.await;
1036    }
1037    let mut exit_code = 0;
1038    // A broken transport during healthy EOF waiting must still cancel the run.
1039    if let Some(mut task) = run_task {
1040        let result = tokio::select! {
1041            biased;
1042            result = &mut task => result,
1043            _ = writer_failed.cancelled() => {
1044                run_cancel.cancel();
1045                let _ = lane.abort().await;
1046                external_tools.shutdown();
1047                task.await
1048            }
1049        };
1050        if let Err(error) = result {
1051            eprintln!("[rpi] rpc prompt task failed: {error}");
1052            exit_code = 1;
1053        }
1054    }
1055    pump_stop.cancel();
1056    harness.session().set_entry_commit_publisher(None);
1057    // Accepted writers have finished. Close the last local sender and let the
1058    // receiver forward queued commits before the output writer is flushed.
1059    drop(commit_tx);
1060    if let Err(error) = commit_task.await {
1061        eprintln!("[rpi] rpc commit event task failed: {error}");
1062        exit_code = 1;
1063    }
1064    let _ = dialog_task.await;
1065    if let Some(task) = event_task {
1066        let _ = task.await;
1067    }
1068    // All producers have joined; close the queue and flush every queued line.
1069    drop(stdout);
1070    match writer_task.await {
1071        Ok(Ok(())) => {}
1072        error => {
1073            eprintln!("[rpi] rpc output failed: {error:?}");
1074            exit_code = 1;
1075        }
1076    }
1077    owner.armed = false;
1078    exit_code
1079}
1080
1081// All dispatcher-created tasks have a retained join, including writer/pumps
1082// and extension commands. Root cancellation transfers ownership to cleanup;
1083// never abort a future which may be joining spawn_blocking work. Cleanup needs
1084// the Tokio runtime to remain alive; an OS write already in progress and plugin
1085// callbacks cannot be forcibly preempted safely.
1086type OwnedRpcRun = RetainedRpcTask<()>;
1087struct RetainedRpcTask<T: Send + 'static> {
1088    task: Option<tokio::task::JoinHandle<T>>,
1089    cancel: tokio_util::sync::CancellationToken,
1090}
1091impl<T: Send + 'static> RetainedRpcTask<T> {
1092    fn new(task: tokio::task::JoinHandle<T>, cancel: tokio_util::sync::CancellationToken) -> Self {
1093        Self {
1094            task: Some(task),
1095            cancel,
1096        }
1097    }
1098    fn is_finished(&self) -> bool {
1099        self.task.as_ref().is_none_or(|task| task.is_finished())
1100    }
1101}
1102impl<T: Send + 'static> std::future::Future for RetainedRpcTask<T> {
1103    type Output = Result<T, tokio::task::JoinError>;
1104    fn poll(
1105        mut self: std::pin::Pin<&mut Self>,
1106        cx: &mut std::task::Context<'_>,
1107    ) -> std::task::Poll<Self::Output> {
1108        let result = std::future::Future::poll(
1109            std::pin::Pin::new(self.task.as_mut().expect("RPC task already joined")),
1110            cx,
1111        );
1112        if result.is_ready() {
1113            self.task.take();
1114        }
1115        result
1116    }
1117}
1118impl<T: Send + 'static> Drop for RetainedRpcTask<T> {
1119    fn drop(&mut self) {
1120        if let Some(task) = self.task.take() {
1121            // Pruning a completed command must not cancel a healthy transport.
1122            if !task.is_finished() {
1123                self.cancel.cancel();
1124            }
1125            tokio::spawn(async move {
1126                if let Err(error) = task.await {
1127                    eprintln!("[rpi] cancelled RPC task cleanup failed: {error}");
1128                }
1129            });
1130        }
1131    }
1132}
1133
1134// Synchronous cleanup survives cancellation/drop of the RPC owner future.
1135// The run is signalled before retiring waiters, preserving aborted settlement.
1136struct RpcOwner<'a> {
1137    harness: &'a AgentHarness,
1138    file_io: Option<crate::rpc::file_io::FileIOClient>,
1139    backend: Arc<crate::rpc::external_tools::ExternalTools>,
1140    run_cancel: tokio_util::sync::CancellationToken,
1141    pump_stop: tokio_util::sync::CancellationToken,
1142    failed: tokio_util::sync::CancellationToken,
1143    mailbox: rpi_extensions::UiDialogMailbox,
1144    armed: bool,
1145}
1146impl Drop for RpcOwner<'_> {
1147    fn drop(&mut self) {
1148        if self.armed {
1149            self.run_cancel.cancel();
1150            if let Some(client) = self.file_io.take() {
1151                client.close();
1152            }
1153            self.backend.shutdown();
1154            self.failed.cancel();
1155            self.pump_stop.cancel();
1156            self.mailbox.detach();
1157            self.harness.session().set_entry_commit_publisher(None);
1158        }
1159    }
1160}
1161
1162fn rpc_get_commands(snapshot: Option<&rpi_extensions::RegistrySnapshot>) -> serde_json::Value {
1163    let commands: Vec<_> = snapshot
1164        .into_iter()
1165        .flat_map(|s| s.commands())
1166        .map(
1167            |command| serde_json::json!({"name": command.name, "description": command.description}),
1168        )
1169        .collect();
1170    serde_json::json!({"commands": commands})
1171}
1172
1173fn rpc_execute_command(
1174    snapshot: Option<&rpi_extensions::RegistrySnapshot>,
1175    request: &serde_json::Value,
1176) -> Result<serde_json::Value, String> {
1177    let name = request
1178        .get("command")
1179        .and_then(|v| v.as_str())
1180        .filter(|name| !name.trim_start_matches('/').is_empty())
1181        .ok_or("command must be a non-empty string")?;
1182    let args = match request.get("args") {
1183        None => "",
1184        Some(value) => value.as_str().ok_or("args must be a string")?,
1185    };
1186    let command = snapshot
1187        .into_iter()
1188        .flat_map(|s| s.commands())
1189        .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1190        .ok_or_else(|| format!("unknown command: {name}"))?;
1191    let input = serde_json::json!({"command": name, "args": args}).to_string();
1192    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1193        let mut out = rpi_plugin_sdk::StbString::empty();
1194        let rc = (command.handler)(
1195            rpi_plugin_sdk::StbStringRef::from_str(&input),
1196            &mut out,
1197            command.user_data,
1198        );
1199        // SAFETY: the callback transfers its output using the host allocator,
1200        // matching the TUI command invocation contract.
1201        let out = unsafe {
1202            rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1203                out,
1204                Some(rpi_extensions::host_free_string),
1205            )
1206        };
1207        if rc != 0 {
1208            return Err(format!("command failed: {name} (code {rc})"));
1209        }
1210        serde_json::from_str(&out.to_string_lossy())
1211            .map_err(|_| format!("command returned invalid JSON: {name}"))
1212    }))
1213    .unwrap_or_else(|_| Err(format!("command panicked: {name}")))
1214}
1215
1216async fn drain_rpc_events(
1217    out: &RpcWriter,
1218    events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
1219) {
1220    loop {
1221        match events.try_recv() {
1222            Ok(event) => write_agent_event(out, &event).await,
1223            Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
1224            Err(_) => break,
1225        }
1226    }
1227}
1228
1229/// Admission owns and drains its blocking decoder even when cancellation wins.
1230/// Bounds are per prompt, and the single run slot bounds workers to one.
1231async fn rpc_validate_prompt(
1232    lane: &Arc<dyn AgentLane>,
1233    command: serde_json::Value,
1234    cancel: &tokio_util::sync::CancellationToken,
1235) -> Result<crate::rpc::prompt_content::ParsedPrompt, String> {
1236    use crate::rpc::prompt_content::parse_prompt_content;
1237    if cancel.is_cancelled() {
1238        return Err("Operation aborted during prompt validation".into());
1239    }
1240    // Capture the model before decoding. Client set_model is excluded until
1241    // settlement. The identity recheck below is not a model lock: extensions
1242    // may still mutate the harness after it, pending a harness-owned run API.
1243    let model = lane.get_model().await.map_err(|e| e.to_string())?;
1244    let mut worker = tokio::task::spawn_blocking(move || parse_prompt_content(&command));
1245    let result = tokio::select! {
1246        biased;
1247        _ = cancel.cancelled() => {
1248            // abort() cannot preempt an active spawn_blocking decoder. Await it;
1249            // never drop/detach its JoinHandle and never persist its result.
1250            let _ = worker.await;
1251            return Err("Operation aborted during prompt validation".into());
1252        }
1253        result = &mut worker => result.map_err(|_| "prompt validation worker failed".to_string())?,
1254    }?;
1255    if cancel.is_cancelled() {
1256        return Err("Operation aborted during prompt validation".into());
1257    }
1258    if result.has_images() {
1259        let current = lane.get_model().await.map_err(|e| e.to_string())?;
1260        if current.id != model.id || current.provider != model.provider || current.api != model.api
1261        {
1262            return Err("model changed during image prompt validation; retry while idle".into());
1263        }
1264        if !model.input.contains(&rpi_ai::types::InputModality::Image)
1265            || !current.input.contains(&rpi_ai::types::InputModality::Image)
1266        {
1267            return Err("selected model does not support image input".into());
1268        }
1269    }
1270    Ok(result)
1271}
1272
1273async fn rpc_configure_file_io(
1274    lane: &Arc<dyn AgentLane>,
1275    run_active: bool,
1276    router: Option<&Arc<rpi_tools::file_io::FileIORouter>>,
1277    owner: &mut Option<crate::rpc::file_io::FileIOClient>,
1278    writer: &RpcWriter,
1279    command: &serde_json::Value,
1280) -> Result<serde_json::Value, String> {
1281    use rpi_tools::file_io::FileIOCapabilities;
1282    if run_active
1283        || lane
1284            .snapshot()
1285            .await
1286            .map_err(|e| e.to_string())?
1287            .active
1288            .is_some()
1289    {
1290        return Err("cannot configure file I/O while a run or prompt validation is active".into());
1291    }
1292    let read_text = command
1293        .get("readText")
1294        .and_then(serde_json::Value::as_bool)
1295        .ok_or("readText must be a boolean")?;
1296    let write_text = command
1297        .get("writeText")
1298        .and_then(serde_json::Value::as_bool)
1299        .ok_or("writeText must be a boolean")?;
1300    let router = router.ok_or("this embedded harness has no attached built-in file I/O router")?;
1301    // Close the old unique owner before installing a fresh weak backend. After
1302    // transport loss the selected router remains client-authoritative: no fallback.
1303    if let Some(old) = owner.take() {
1304        old.close();
1305    }
1306    let next =
1307        (read_text || write_text).then(|| crate::rpc::file_io::FileIOClient::new(writer.clone()));
1308    router
1309        .configure(
1310            FileIOCapabilities {
1311                read_text,
1312                write_text,
1313            },
1314            next.as_ref().map(|c| c.backend()),
1315        )
1316        .await
1317        .map_err(|e| e.to_string())?;
1318    *owner = next;
1319    Ok(serde_json::json!({"readText": read_text, "writeText": write_text}))
1320}
1321
1322/// Cancellation is delivered even if abort/stop arrives before prompt_text has
1323/// registered a run. Once active, await its normal aborted outcome and cleanup.
1324async fn rpc_prompt_cancellable(
1325    lane: &Arc<dyn AgentLane>,
1326    content: crate::rpc::prompt_content::ParsedPrompt,
1327    cancel: tokio_util::sync::CancellationToken,
1328) -> Result<serde_json::Value, String> {
1329    if cancel.is_cancelled() {
1330        return Err("Operation aborted".into());
1331    }
1332    let prompt = rpc_prompt_content(lane, content);
1333    tokio::pin!(prompt);
1334    tokio::select! {
1335        biased;
1336        _ = cancel.cancelled() => {
1337            // `abort()` reclaims the queue atomically. The entries are not
1338            // dropped: they ride back on the settlement so the client can
1339            // restore what the user staged while the run was in flight.
1340            match lane.abort().await {
1341                Ok(reclaimed) => prompt.await.map(|mut value| {
1342                    if let Some(object) = value.as_object_mut() {
1343                        object.insert(
1344                            "reclaimed".to_string(),
1345                            reclaimed_payload(&reclaimed),
1346                        );
1347                    }
1348                    value
1349                }),
1350                Err(_) => Err("Operation aborted".to_string()),
1351            }
1352        }
1353        result = &mut prompt => result,
1354    }
1355}
1356
1357/// Reclaimed queue entries from an abort, for replies that also settle a run.
1358fn reclaimed_payload(result: &rpi_harness::agent_harness::AbortResult) -> serde_json::Value {
1359    serde_json::json!({
1360        "steer": crate::extensions_actions::queued_message_payloads(&result.steer),
1361        "followUp": crate::extensions_actions::queued_message_payloads(&result.follow_up),
1362    })
1363}
1364
1365/// Compact the branch, unless the caller cancels first.
1366///
1367/// Compaction is its own operation kind in the harness: it spends a provider
1368/// call on the summary, so it takes the same single-run slot and reports the
1369/// same settlement shape a prompt does.
1370async fn rpc_compact_cancellable(
1371    lane: &Arc<dyn AgentLane>,
1372    instructions: Option<String>,
1373    cancel: tokio_util::sync::CancellationToken,
1374) -> Result<serde_json::Value, String> {
1375    let compact = async {
1376        match lane.compact(instructions.as_deref()).await {
1377            Ok(result) => Ok(compaction_json(&result)),
1378            Err(error) => Err(error.to_string()),
1379        }
1380    };
1381    tokio::pin!(compact);
1382    tokio::select! {
1383        biased;
1384        _ = cancel.cancelled() => {
1385            let _ = lane.abort().await;
1386            compact.await
1387        }
1388        result = &mut compact => result,
1389    }
1390}
1391
1392/// Reduce a compaction result to what a client needs: the verdict, and a line
1393/// a transcript can print.
1394fn compaction_json(result: &CompactionResult) -> serde_json::Value {
1395    let mut value = match &result.outcome {
1396        CompactionOutcome::Completed { leaf_id, entry } => {
1397            let (tokens_before, retained) = match entry {
1398                rpi_harness::session::types::Entry::Compaction(compaction) => {
1399                    (compaction.tokens_before, compaction.retained_tail.len())
1400                }
1401                _ => (0, 0),
1402            };
1403            serde_json::json!({
1404                "outcome": "compacted",
1405                "leafId": leaf_id,
1406                "tokensBefore": tokens_before,
1407                "retainedMessages": retained,
1408                "summary": format!(
1409                    "Conversation compacted · {} tokens before, {} messages kept.",
1410                    tokens_before, retained
1411                ),
1412            })
1413        }
1414        CompactionOutcome::Declined { leaf_id } => serde_json::json!({
1415            "outcome": "declined",
1416            "leafId": leaf_id,
1417            "summary": "Nothing to compact yet.",
1418        }),
1419        CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
1420            "outcome": "aborted",
1421            "leafId": leaf_id,
1422            "summary": "Compaction aborted.",
1423        }),
1424        CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
1425            "outcome": "failed",
1426            "leafId": leaf_id,
1427            "error": error.message,
1428            "summary": format!("Compaction failed: {}", error.message),
1429        }),
1430    };
1431    value["runId"] = serde_json::json!(result.run_id);
1432    value
1433}
1434
1435/// Drive the run recovery left behind, unless the caller cancels first.
1436///
1437/// `Ok(None)` from the lane means there was nothing to resume: the run is
1438/// reported as `skipped` so the client can settle without inventing an outcome.
1439async fn rpc_resume_cancellable(
1440    lane: &Arc<dyn AgentLane>,
1441    cancel: tokio_util::sync::CancellationToken,
1442) -> Result<serde_json::Value, String> {
1443    let resume = async {
1444        match lane.resume_pending().await {
1445            Ok(Some(result)) => {
1446                let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
1447                Ok(serde_json::json!({
1448                    "runId": result.run_id,
1449                    "outcome": outcome,
1450                    "finalText": final_text,
1451                    "error": error,
1452                    "resumed": true,
1453                }))
1454            }
1455            Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
1456            Err(error) => Err(error.to_string()),
1457        }
1458    };
1459    tokio::pin!(resume);
1460    tokio::select! {
1461        biased;
1462        _ = cancel.cancelled() => {
1463            let _ = lane.abort().await;
1464            resume.await
1465        }
1466        result = &mut resume => result,
1467    }
1468}
1469
1470fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
1471    match outcome {
1472        HarnessRunOutcome::Completed { final_message, .. } => {
1473            ("completed", Some(assistant_text(final_message)), None)
1474        }
1475        HarnessRunOutcome::Aborted { final_message, .. } => {
1476            ("aborted", Some(assistant_text(final_message)), None)
1477        }
1478        // A failed run can end with nothing to say, which is exactly when the
1479        // reason matters: rate limits and provider errors live here.
1480        HarnessRunOutcome::Failed {
1481            final_message,
1482            error,
1483            ..
1484        } => (
1485            "failed",
1486            final_message.as_ref().map(assistant_text),
1487            Some(error.message.clone()),
1488        ),
1489        HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
1490    }
1491}
1492
1493async fn rpc_prompt_content(
1494    lane: &Arc<dyn AgentLane>,
1495    content: crate::rpc::prompt_content::ParsedPrompt,
1496) -> Result<serde_json::Value, String> {
1497    use crate::rpc::prompt_content::ParsedPrompt;
1498    let result = match content {
1499        ParsedPrompt::Legacy(text) => lane.prompt_text(&text, Vec::new()).await,
1500        ParsedPrompt::Blocks(blocks) => {
1501            lane.prompt_message(rpi_agent::AgentMessage::User(UserMessage::new(
1502                rpi_ai::types::UserContent::Blocks(blocks),
1503                rpc_now_ms(),
1504            )))
1505            .await
1506        }
1507    }
1508    .map_err(|e| e.to_string())?;
1509    let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
1510    Ok(
1511        serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
1512    )
1513}
1514
1515async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1516    let model = lane.get_model().await.ok();
1517    let thinking = lane
1518        .get_thinking_level()
1519        .await
1520        .ok()
1521        .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
1522    let active_tools = if harness.has_exact_resources() {
1523        harness
1524            .execution_context()
1525            .ok()
1526            .and_then(|v| v.get("tools").cloned())
1527            .unwrap_or_else(|| serde_json::json!([]))
1528    } else {
1529        serde_json::json!(lane.get_active_tools().await.unwrap_or_default())
1530    };
1531    let leaf_id = lane.get_leaf_id().await.ok().flatten();
1532    let context = rpc_context_usage(lane, harness).await;
1533    let active_operation = lane.active_operation().await.ok().flatten().map(|active| {
1534        serde_json::json!({
1535            "runId": active.run_id,
1536            "lane": active.lane,
1537            "kind": format!("{:?}", active.kind).to_lowercase(),
1538        })
1539    });
1540    let queued = lane.queued_messages().await.unwrap_or_default();
1541    let stats = rpc_get_stats(lane, harness).await.ok();
1542    serde_json::json!({
1543        "modelId": model.as_ref().map(|model| model.id.clone()),
1544        "model": model.as_ref().map(|model| model.id.clone()),
1545        "provider": model.as_ref().map(|model| model.provider.clone()),
1546        "thinkingLevel": thinking,
1547        "activeTools": active_tools,
1548        "leafId": leaf_id,
1549        "context": context,
1550        "sessionId": harness.session().storage().metadata().id,
1551        "activeOperation": active_operation,
1552        "queue": {
1553            // Text-only view: these consumers render a single-line pending row.
1554            // Attachments travel in the `Abort` / `ClearQueue` replies, which
1555            // keep the per-message pairing.
1556            //
1557            // Each queue is serialized on its own. `texts()` chains steering and
1558            // follow-up, so using it here would put follow-up texts in the
1559            // `steering` field and duplicate them in `followUp`.
1560            "steering": queued
1561                .steering
1562                .iter()
1563                .map(|m| m.text.as_str())
1564                .collect::<Vec<_>>(),
1565            "followUp": queued.follow_up.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
1566            "steeringCount": queued.steering.len(),
1567            "followUpCount": queued.follow_up.len(),
1568        },
1569        "stats": stats,
1570    })
1571}
1572
1573fn rpc_now_ms() -> i64 {
1574    std::time::SystemTime::now()
1575        .duration_since(std::time::UNIX_EPOCH)
1576        .map(|duration| duration.as_millis() as i64)
1577        .unwrap_or_default()
1578}
1579
1580fn rpc_message_text(value: &serde_json::Value) -> String {
1581    let Some(content) = value.get("content") else {
1582        return String::new();
1583    };
1584    if let Some(text) = content.as_str() {
1585        return text.to_string();
1586    }
1587    content
1588        .as_array()
1589        .into_iter()
1590        .flatten()
1591        .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
1592        .collect::<Vec<_>>()
1593        .join("\n")
1594}
1595
1596async fn rpc_get_history(
1597    lane: &Arc<dyn AgentLane>,
1598    harness: &AgentHarness,
1599    msg: &serde_json::Value,
1600) -> Result<serde_json::Value, String> {
1601    let limit = msg
1602        .get("limit")
1603        .and_then(serde_json::Value::as_u64)
1604        .unwrap_or(100)
1605        .clamp(1, 500) as usize;
1606    let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
1607    let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
1608    let page = harness
1609        .session()
1610        .record_page(&rpi_harness::session::types::RecordPageQuery {
1611            lane: lane.name().to_owned(),
1612            after_seq,
1613            before_seq,
1614            limit,
1615            from_end: before_seq.is_some() || after_seq.is_none(),
1616        })
1617        .await
1618        .map_err(|error| error.to_string())?;
1619    let records = page.records;
1620    let total = page.total;
1621    let has_more = total > records.len();
1622    let first_seq = records.first().map(LaneRecord::seq);
1623    let last_seq = records.last().map(LaneRecord::seq);
1624    let open_operations = harness
1625        .session()
1626        .find_open_operations(lane.name(), Some(50))
1627        .await
1628        .map_err(|error| error.to_string())?;
1629    Ok(serde_json::json!({
1630        "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1631        "total": total,
1632        "limit": limit,
1633        "afterSeq": after_seq,
1634        "beforeSeq": before_seq,
1635        "firstSeq": first_seq,
1636        "lastSeq": last_seq,
1637        "hasMore": has_more,
1638        "leafId": lane.get_leaf_id().await.ok().flatten(),
1639        "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1640    }))
1641}
1642
1643async fn rpc_get_session_branch(
1644    harness: &AgentHarness,
1645    msg: &serde_json::Value,
1646) -> Result<serde_json::Value, String> {
1647    // Envelope fields are not query arguments. Everything else is validated by
1648    // the same strict parser used by the runtime action.
1649    let mut args = msg.clone();
1650    if let Some(args) = args.as_object_mut() {
1651        args.remove("type");
1652        args.remove("id");
1653    }
1654    let request = branch_entry_query(&args)?;
1655    let (entries, session_id, leaf_id) = branch_snapshot(harness, &request).await?;
1656    let entries = entries
1657        .iter()
1658        .map(|entry| serde_json::to_value(entry).map_err(|error| error.to_string()))
1659        .collect::<Result<Vec<_>, _>>()?;
1660    Ok(serde_json::json!({
1661        "entries": entries,
1662        "count": entries.len(),
1663        "filtered": true,
1664        "sessionId": session_id,
1665        "leafId": leaf_id,
1666        "scopedAppend": true,
1667    }))
1668}
1669
1670#[derive(Debug, Clone, PartialEq, Eq)]
1671pub(crate) struct BranchEntryQuery {
1672    pub custom_types: Vec<String>,
1673    pub order: EntryOrder,
1674    pub limit: Option<usize>,
1675    pub after_seq: Option<u64>,
1676}
1677
1678impl BranchEntryQuery {
1679    fn entry_queries(&self) -> Vec<EntryQuery> {
1680        // A global top-L entry must be within its type's top L under the same
1681        // order/cursor. Bound cloned payloads before the global merge as well.
1682        let per_query_limit = self.limit;
1683        self.custom_types
1684            .iter()
1685            .map(|custom_type| EntryQuery {
1686                entry_type: Some("custom"),
1687                custom_type: Some(custom_type.clone()),
1688                order: Some(self.order),
1689                limit: per_query_limit,
1690                cursor: self.after_seq.map(|after_seq| EntryCursor { after_seq }),
1691                ..Default::default()
1692            })
1693            .collect()
1694    }
1695}
1696
1697pub(crate) async fn branch_snapshot(
1698    harness: &AgentHarness,
1699    request: &BranchEntryQuery,
1700) -> Result<(Vec<Entry>, String, Option<String>), String> {
1701    // Pin both the storage binding and branch tip once. Session handles follow
1702    // harness session switches, and resolving the live leaf per type can merge
1703    // entries from different branches while a query is in flight.
1704    let storage = harness.session().storage();
1705    let leaf_id = storage
1706        .get_lanes()
1707        .await
1708        .map_err(|error| error.to_string())?
1709        .into_iter()
1710        .find(|pointer| pointer.lane == "main")
1711        .ok_or_else(|| "Lane not found: main".to_string())?
1712        .leaf_id;
1713    let session_id = storage.metadata().id;
1714    let Some(start) = leaf_id.clone() else {
1715        return Ok((Vec::new(), session_id, None));
1716    };
1717    let bounds = BranchBounds {
1718        start: Some(start.clone()),
1719        ..Default::default()
1720    };
1721    let mut entries = Vec::new();
1722    for query in request.entry_queries() {
1723        entries.extend(
1724            storage
1725                .find_entries_on_branch(&query, &bounds, &start)
1726                .await
1727                .map_err(|error| error.to_string())?,
1728        );
1729    }
1730    if request.custom_types.len() > 1 {
1731        let mut seen = HashSet::with_capacity(entries.len());
1732        entries.retain(|entry| seen.insert(entry.id().to_owned()));
1733        match request.order {
1734            EntryOrder::OldestFirst => entries.sort_by_key(Entry::seq),
1735            EntryOrder::NewestFirst => entries.sort_by_key(|entry| Reverse(entry.seq())),
1736        }
1737        if let Some(limit) = request.limit {
1738            entries.truncate(limit);
1739        }
1740    }
1741    Ok((entries, session_id, leaf_id))
1742}
1743
1744/// Canonical custom-entry query. Reject unknown/legacy arguments even when
1745/// valid modern arguments are also present; never fall back to a full scan.
1746pub(crate) fn branch_entry_query(msg: &serde_json::Value) -> Result<BranchEntryQuery, String> {
1747    let args = msg
1748        .as_object()
1749        .ok_or("get_session_branch: query must be an object")?;
1750    for key in args.keys() {
1751        if !matches!(key.as_str(), "customTypes" | "afterSeq" | "limit" | "order") {
1752            return Err(format!(
1753                "get_session_branch: unknown query argument `{key}`"
1754            ));
1755        }
1756    }
1757    let values = args
1758        .get("customTypes")
1759        .and_then(serde_json::Value::as_array)
1760        .filter(|values| !values.is_empty())
1761        .ok_or(
1762            "get_session_branch: `customTypes` is required and must be a non-empty string array",
1763        )?;
1764    let mut custom_types = Vec::with_capacity(values.len());
1765    for value in values {
1766        let custom_type = value
1767            .as_str()
1768            .filter(|value| !value.trim().is_empty())
1769            .ok_or("get_session_branch: every `customTypes` item must be a non-empty string")?;
1770        if !custom_types.iter().any(|existing| existing == custom_type) {
1771            custom_types.push(custom_type.to_owned());
1772        }
1773    }
1774    let limit = match msg.get("limit") {
1775        None | Some(serde_json::Value::Null) => None,
1776        Some(value) => {
1777            let limit = value
1778                .as_u64()
1779                .filter(|limit| *limit > 0)
1780                .ok_or("get_session_branch: `limit` must be a positive integer")?;
1781            Some(usize::try_from(limit).map_err(|_| "get_session_branch: `limit` is too large")?)
1782        }
1783    };
1784    let order = match msg.get("order") {
1785        None => EntryOrder::OldestFirst,
1786        Some(serde_json::Value::String(value)) if value == "oldestFirst" => EntryOrder::OldestFirst,
1787        Some(serde_json::Value::String(value)) if value == "newestFirst" => EntryOrder::NewestFirst,
1788        Some(_) => {
1789            return Err("get_session_branch: `order` must be oldestFirst or newestFirst".into())
1790        }
1791    };
1792    let after_seq = match msg.get("afterSeq") {
1793        None | Some(serde_json::Value::Null) => None,
1794        Some(value) => Some(
1795            value
1796                .as_u64()
1797                .ok_or("get_session_branch: `afterSeq` must be a non-negative integer")?,
1798        ),
1799    };
1800    Ok(BranchEntryQuery {
1801        custom_types,
1802        order,
1803        limit,
1804        after_seq,
1805    })
1806}
1807
1808async fn rpc_append_entry(
1809    harness: &AgentHarness,
1810    msg: &serde_json::Value,
1811) -> Result<serde_json::Value, String> {
1812    let custom_type = msg
1813        .get("customType")
1814        .and_then(serde_json::Value::as_str)
1815        .filter(|value| !value.trim().is_empty())
1816        .ok_or_else(|| "append_entry requires a non-empty string `customType`".to_string())?;
1817    crate::resource_selection::validate_public_entry(custom_type, msg.get("data"))?;
1818    let entry_id = if let Some(expected) = msg.get("expected") {
1819        let guard = serde_json::from_value(expected.clone())
1820            .map_err(|error| format!("invalid expected guard: {error}"))?;
1821        harness
1822            .append_scoped_entry(
1823                &guard,
1824                rpi_harness::agent_harness::ScopedEntry::Custom {
1825                    custom_type: custom_type.to_owned(),
1826                    data: msg.get("data").cloned(),
1827                },
1828            )
1829            .await
1830    } else {
1831        harness
1832            .append_custom_entry(custom_type, msg.get("data").cloned())
1833            .await
1834    }
1835    .map_err(|error| error.to_string())?;
1836    let mut response = serde_json::json!({"entryId": entry_id, "customType": custom_type});
1837    if msg.get("expected").is_some() {
1838        response["scoped"] = serde_json::json!(true);
1839    }
1840    Ok(response)
1841}
1842
1843async fn rpc_get_stats(
1844    lane: &Arc<dyn AgentLane>,
1845    harness: &AgentHarness,
1846) -> Result<serde_json::Value, String> {
1847    let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1848    // Filter before cloning/hydrating; legacy streaming progress is unrelated.
1849    let mut records = Vec::new();
1850    for record_type in [
1851        "operation_started",
1852        "operation_finished",
1853        "retry_pending",
1854        "usage",
1855    ] {
1856        records.extend(
1857            harness
1858                .session()
1859                .find_records(&RecordQuery {
1860                    lane: Some(lane.name().to_string()),
1861                    record_type: Some(record_type),
1862                    order: Some(EntryOrder::OldestFirst),
1863                    ..Default::default()
1864                })
1865                .await
1866                .map_err(|error| error.to_string())?,
1867        );
1868    }
1869    records.sort_unstable_by_key(LaneRecord::seq);
1870    let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1871    for record in &records {
1872        let kind = match record {
1873            LaneRecord::OperationStarted(started) => {
1874                let key = started.intent.kind();
1875                *operation_counts.entry(key.to_string()).or_default() += 1;
1876                if matches!(
1877                    &started.intent,
1878                    rpi_harness::session::types::OperationIntent::Run {
1879                        resume_data: Some(_),
1880                        ..
1881                    }
1882                ) {
1883                    *operation_counts.entry("resume".to_string()).or_default() += 1;
1884                }
1885                continue;
1886            }
1887            LaneRecord::OperationFinished(_) => "operation_finished",
1888            LaneRecord::RetryPending(_) => "retry_pending",
1889            LaneRecord::Usage(_) => "usage",
1890            _ => continue,
1891        };
1892        *operation_counts.entry(kind.to_string()).or_default() += 1;
1893    }
1894    let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1895    let model = lane.get_model().await.ok();
1896    let input = stats.cached_tokens + stats.uncached_tokens;
1897    Ok(serde_json::json!({
1898        "messageCount": stats.message_count,
1899        "operationCount": count("operation_finished"),
1900        "retryCount": count("retry_pending"),
1901        "compactionCount": count("compaction"),
1902        "resumeCount": count("resume"),
1903        "operationCounts": operation_counts,
1904        "provider": model.as_ref().map(|value| value.provider.clone()),
1905        "modelId": model.as_ref().map(|value| value.id.clone()),
1906        "cachedTokens": stats.cached_tokens,
1907        "uncachedTokens": stats.uncached_tokens,
1908        "totalTokens": stats.total_tokens,
1909        "costTotal": stats.cost_total,
1910        "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1911    }))
1912}
1913
1914async fn rpc_get_queue(
1915    lane: &Arc<dyn AgentLane>,
1916    harness: &AgentHarness,
1917    msg: &serde_json::Value,
1918) -> Result<serde_json::Value, String> {
1919    let include_completed = msg
1920        .get("includeCompleted")
1921        .and_then(serde_json::Value::as_bool)
1922        .unwrap_or(false);
1923    // Filter before cloning/hydrating; legacy streaming progress is unrelated.
1924    let mut records = Vec::new();
1925    for record_type in ["queue_enqueued", "queue_cancelled"] {
1926        records.extend(
1927            harness
1928                .session()
1929                .find_records(&RecordQuery {
1930                    lane: Some(lane.name().to_string()),
1931                    record_type: Some(record_type),
1932                    order: Some(EntryOrder::OldestFirst),
1933                    ..Default::default()
1934                })
1935                .await
1936                .map_err(|error| error.to_string())?,
1937        );
1938    }
1939    records.sort_unstable_by_key(LaneRecord::seq);
1940    let mut cancelled = std::collections::HashMap::<String, u64>::new();
1941    for record in &records {
1942        if let LaneRecord::QueueCancelled(record) = record {
1943            cancelled.insert(record.entry_id.clone(), record.base.seq);
1944        }
1945    }
1946    let mut items = Vec::new();
1947    for record in records {
1948        let LaneRecord::QueueEnqueued(record) = record else {
1949            continue;
1950        };
1951        let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1952            continue;
1953        };
1954        let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1955            ("cancelled", Some(*seq))
1956        } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1957            ("consumed", Some(entry.base().seq))
1958        } else {
1959            ("queued", None)
1960        };
1961        if status != "queued" && !include_completed {
1962            continue;
1963        }
1964        let message = record
1965            .target
1966            .get("message")
1967            .cloned()
1968            .unwrap_or(serde_json::Value::Null);
1969        items.push(serde_json::json!({
1970            "entryId": entry_id,
1971            "queue": record.queue,
1972            "status": status,
1973            "text": rpc_message_text(&message),
1974            "message": message,
1975            "runId": record.run_id,
1976            "seq": record.base.seq,
1977            "completedSeq": completed_seq,
1978        }));
1979    }
1980    Ok(
1981        serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1982    )
1983}
1984
1985async fn rpc_queue_message(
1986    lane: &Arc<dyn AgentLane>,
1987    msg: &serde_json::Value,
1988) -> Result<serde_json::Value, String> {
1989    let text = msg
1990        .get("message")
1991        .and_then(serde_json::Value::as_str)
1992        .filter(|text| !text.trim().is_empty())
1993        .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1994    let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1995    let queue = msg
1996        .get("deliverAs")
1997        .and_then(serde_json::Value::as_str)
1998        .unwrap_or("followUp");
1999    let result = match queue {
2000        "steer" => lane.steer(message).await,
2001        "followUp" | "follow_up" => lane.follow_up(message).await,
2002        "nextTurn" | "next_run" => lane.next_run(message).await,
2003        other => return Err(format!("unknown queue kind: {other}")),
2004    }
2005    .map_err(|error| error.to_string())?;
2006    Ok(serde_json::json!({
2007        "accepted": true,
2008        "entryId": result.entry_id,
2009        "queue": queue,
2010    }))
2011}
2012
2013async fn rpc_cancel_queued(
2014    lane: &Arc<dyn AgentLane>,
2015    msg: &serde_json::Value,
2016) -> Result<serde_json::Value, String> {
2017    let entry_id = msg
2018        .get("entryId")
2019        .and_then(serde_json::Value::as_str)
2020        .filter(|value| !value.is_empty())
2021        .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
2022    let result = lane
2023        .cancel_queued(entry_id)
2024        .await
2025        .map_err(|error| error.to_string())?;
2026    Ok(serde_json::json!({
2027        "entryId": entry_id,
2028        "outcome": format!("{:?}", result.outcome).to_lowercase(),
2029    }))
2030}
2031
2032/// The `abort` reply. Reclaimed queue entries travel back as `{text, images}`
2033/// so a remote UI can restore them instead of losing staged input.
2034fn abort_response(result: &rpi_harness::agent_harness::AbortResult) -> serde_json::Value {
2035    serde_json::json!({
2036        "aborted": true,
2037        "steer": crate::extensions_actions::queued_message_payloads(&result.steer),
2038        "followUp": crate::extensions_actions::queued_message_payloads(&result.follow_up),
2039    })
2040}
2041
2042/// Map a lane abort to its RPC reply.
2043///
2044/// A stop button is idempotent: an already-idle lane reports success with
2045/// nothing reclaimed instead of surfacing `NoActiveRun`. Any other error is
2046/// still reported.
2047fn abort_result_reply(
2048    result: Result<rpi_harness::agent_harness::AbortResult, rpi_harness::result::HarnessError>,
2049) -> Result<serde_json::Value, String> {
2050    match result {
2051        Ok(result) => Ok(abort_response(&result)),
2052        Err(rpi_harness::result::HarnessError::NoActiveRun { .. }) => {
2053            Ok(abort_response(&rpi_harness::agent_harness::AbortResult {
2054                run_id: String::new(),
2055                steer: Vec::new(),
2056                follow_up: Vec::new(),
2057            }))
2058        }
2059        Err(error) => Err(error.to_string()),
2060    }
2061}
2062
2063async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
2064    let result = lane
2065        .clear_queue()
2066        .await
2067        .map_err(|error| error.to_string())?;
2068    Ok(serde_json::json!({
2069        "cleared": result.steering.len() + result.follow_up.len(),
2070        // Independent per-queue views; `texts()` would mix the two and
2071        // duplicate follow-up entries into `steering`.
2072        "steering": result
2073            .steering
2074            .iter()
2075            .map(|m| m.text.as_str())
2076            .collect::<Vec<_>>(),
2077        "followUp": result.follow_up.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
2078    }))
2079}
2080
2081async fn rpc_navigate(
2082    lane: &Arc<dyn AgentLane>,
2083    msg: &serde_json::Value,
2084) -> Result<serde_json::Value, String> {
2085    let target_id = msg
2086        .get("targetId")
2087        .and_then(serde_json::Value::as_str)
2088        .filter(|value| !value.is_empty());
2089    if lane
2090        .snapshot()
2091        .await
2092        .map_err(|error| error.to_string())?
2093        .active
2094        .is_some()
2095    {
2096        return Err("cannot navigate while an operation is active".to_string());
2097    }
2098    let result = lane
2099        .navigate_tree(target_id, false, None, None)
2100        .await
2101        .map_err(|error| error.to_string())?;
2102    let (outcome, leaf_id, error) = match result.outcome {
2103        rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
2104            ("completed", new_leaf_id, None)
2105        }
2106        rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
2107            ("declined", leaf_id, None)
2108        }
2109        rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
2110            ("aborted", leaf_id, None)
2111        }
2112        rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
2113            ("failed", leaf_id, Some(error.message))
2114        }
2115    };
2116    Ok(serde_json::json!({
2117        "runId": result.run_id,
2118        "outcome": outcome,
2119        "leafId": leaf_id,
2120        "error": error,
2121    }))
2122}
2123
2124/// How full the model's context is, measured the way compaction measures it.
2125///
2126/// The branch is projected into model input first (that is what applies a
2127/// compaction boundary), so the number matches what the next provider call will
2128/// actually send rather than how many messages the transcript happens to hold.
2129async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
2130    use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
2131    use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
2132
2133    let estimate = match harness.context_usage().await {
2134        Ok(Some(estimate)) => estimate,
2135        Ok(None) => {
2136            // Legacy/custom backends keep the original default projection.
2137            let entries = harness
2138                .find_entries_on_branch(
2139                    &EntryQuery {
2140                        order: Some(EntryOrder::OldestFirst),
2141                        ..Default::default()
2142                    },
2143                    &BranchBounds::default(),
2144                )
2145                .await
2146                .unwrap_or_default();
2147            estimate_context_tokens(&build_session_context(&entries).messages)
2148        }
2149        // State polling has always been best effort: a read failure yielded
2150        // an empty estimate. Do not retry a supported backend by reading all
2151        // bodies again, or turn its failure into an "unsupported" capability.
2152        Err(_) => estimate_context_tokens(&[]),
2153    };
2154    let window = lane
2155        .get_model()
2156        .await
2157        .ok()
2158        .map(|model| model.context_window)
2159        .unwrap_or(0);
2160    let settings = harness.compaction_settings();
2161    let window = window as i64;
2162    let threshold = window - settings.reserve_tokens;
2163    let percent = if window > 0 {
2164        (estimate.tokens as f64) * 100.0 / (window as f64)
2165    } else {
2166        0.0
2167    };
2168    serde_json::json!({
2169        "tokens": estimate.tokens,
2170        "window": window,
2171        "threshold": threshold,
2172        "percent": (percent * 10.0).round() / 10.0,
2173        "reserveTokens": settings.reserve_tokens,
2174        "keepRecentTokens": settings.keep_recent_tokens,
2175        "compactionEnabled": settings.enabled,
2176        // False once a real provider usage report backs the number; until then
2177        // it is a character-based estimate.
2178        "estimated": estimate.last_usage_index.is_none(),
2179        "trailingTokens": estimate.trailing_tokens,
2180    })
2181}
2182
2183/// The window `get_messages` returns, expressed as a slice range plus whether an
2184/// older page still exists. `before` is the index of the `beforeId` entry.
2185fn page_window(
2186    total: usize,
2187    limit: usize,
2188    before: Option<usize>,
2189) -> (std::ops::Range<usize>, bool) {
2190    match before {
2191        Some(index) => {
2192            let start = index.saturating_sub(limit);
2193            (start..index, start > 0)
2194        }
2195        None => {
2196            let skip = total.saturating_sub(limit);
2197            (skip..total, skip > 0)
2198        }
2199    }
2200}
2201
2202/// Read-only snapshot of the current branch, oldest first. Messages are
2203/// projected with the same `AgentMessage` encoding the event stream uses, so a
2204/// client can render a transcript without understanding session entries.
2205async fn rpc_get_messages(
2206    lane: &Arc<dyn AgentLane>,
2207    harness: &AgentHarness,
2208    msg: &serde_json::Value,
2209) -> Result<serde_json::Value, String> {
2210    use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
2211
2212    const DEFAULT_LIMIT: usize = 400;
2213    let limit = msg
2214        .get("limit")
2215        .and_then(|value| value.as_u64())
2216        .map(|value| value as usize)
2217        .unwrap_or(DEFAULT_LIMIT)
2218        .max(1);
2219    // `beforeId` pages backwards: the returned window ends immediately before
2220    // the entry with that id, so a client can walk a long branch one page at a
2221    // time instead of always re-reading the tail.
2222    let before_id = msg
2223        .get("beforeId")
2224        .and_then(|value| value.as_str())
2225        .filter(|value| !value.is_empty())
2226        .map(str::to_owned);
2227    let selected = harness
2228        .transcript_page(&rpi_harness::session::types::TranscriptPageQuery {
2229            limit,
2230            before_id: before_id.clone(),
2231        })
2232        .await
2233        .map_err(|error| error.to_string())?;
2234    let (window, total, has_more, leaf_id) = if let Some(page) = selected {
2235        // The storage pins/counts the branch using metadata and hydrates only
2236        // this window. Use its snapshot leaf, not a later lane position.
2237        (
2238            rpc_project_transcript(page.entries)?,
2239            page.total,
2240            page.has_more,
2241            page.leaf_id,
2242        )
2243    } else {
2244        // Legacy/custom storage keeps the original projection and error rules.
2245        let entries = harness
2246            .find_entries_on_branch(
2247                &EntryQuery {
2248                    order: Some(EntryOrder::OldestFirst),
2249                    ..Default::default()
2250                },
2251                &BranchBounds::default(),
2252            )
2253            .await
2254            .map_err(|error| error.to_string())?;
2255        let mut items = rpc_project_transcript(entries)?;
2256        let total = items.len();
2257        let before_index = match &before_id {
2258            Some(before) => Some(
2259                items
2260                    .iter()
2261                    .position(|(id, _)| id == before)
2262                    .ok_or_else(|| format!("beforeId not found: {before}"))?,
2263            ),
2264            None => None,
2265        };
2266        let (range, has_more) = page_window(total, limit, before_index);
2267        let window = items.drain(range).collect::<Vec<_>>();
2268        (
2269            window,
2270            total,
2271            has_more,
2272            lane.get_leaf_id().await.ok().flatten(),
2273        )
2274    };
2275    let first_id = window.first().map(|(id, _)| id.clone());
2276    let last_id = window.last().map(|(id, _)| id.clone());
2277    let messages = window
2278        .into_iter()
2279        .map(|(_, value)| value)
2280        .collect::<Vec<_>>();
2281    let pending_resume = lane.has_pending_resume().await;
2282    Ok(serde_json::json!({
2283        "messages": messages,
2284        "total": total,
2285        "truncated": has_more,
2286        "limit": limit,
2287        "firstId": first_id,
2288        "lastId": last_id,
2289        "hasMore": has_more,
2290        "beforeId": before_id,
2291        "leafId": leaf_id,
2292        "pendingResume": pending_resume,
2293    }))
2294}
2295
2296fn rpc_project_transcript(
2297    entries: Vec<rpi_harness::session::types::Entry>,
2298) -> Result<Vec<(String, serde_json::Value)>, String> {
2299    use rpi_harness::session::types::Entry;
2300    let mut items = Vec::new();
2301    for entry in entries {
2302        match entry {
2303            Entry::Message(entry) => {
2304                let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
2305                // Use the session entry ID for stable beforeId cursors.
2306                value["entryId"] = serde_json::json!(entry.base.id);
2307                items.push((entry.base.id, value));
2308            }
2309            Entry::Compaction(compaction) => items.push((
2310                compaction.base.id.clone(),
2311                serde_json::json!({
2312                    "role": "compaction",
2313                    "entryId": compaction.base.id,
2314                    "tokensBefore": compaction.tokens_before,
2315                    "retainedMessages": compaction.retained_tail.len(),
2316                    "timestamp": compaction.base.timestamp,
2317                }),
2318            )),
2319            _ => {}
2320        }
2321    }
2322    Ok(items)
2323}
2324
2325fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
2326    let mut providers =
2327        std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
2328    providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
2329    if let Ok(models) = crate::config::load_models_config() {
2330        providers.extend(models.providers.keys().cloned());
2331    }
2332    providers
2333}
2334
2335fn rpc_get_auth_status(
2336    model_catalog: &[rpi_ai::Model],
2337    oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
2338) -> serde_json::Value {
2339    let store = crate::config::read_auth().unwrap_or_default();
2340    let models = crate::config::load_models_config().unwrap_or_default();
2341    let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
2342        snapshot
2343            .providers()
2344            .iter()
2345            .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
2346            .collect()
2347    });
2348    let auth_states = crate::credentials::statuses(&models, &store, &manifests);
2349    let providers = rpc_auth_provider_ids(model_catalog)
2350        .into_iter()
2351        .map(|provider| {
2352            let credential = store.get(&provider);
2353            let state = auth_states.get(&provider).cloned().unwrap_or(
2354                rpi_runtime::credentials::AuthState {
2355                    status: rpi_runtime::credentials::AuthStatus::Missing,
2356                    source: crate::credentials::CredentialSource::Missing,
2357                },
2358            );
2359            let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
2360            let oauth_available =
2361                oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
2362            serde_json::json!({
2363                "provider": provider,
2364                "ready": state.is_ready(),
2365                "source": state.source.as_json_str(),
2366                "status": state.status,
2367                "oauth": oauth,
2368                "oauthAvailable": oauth_available,
2369            })
2370        })
2371        .collect::<Vec<_>>();
2372    serde_json::json!({"providers": providers})
2373}
2374
2375fn rpc_oauth(
2376    snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
2377    msg: &serde_json::Value,
2378) -> Result<serde_json::Value, String> {
2379    let snapshot = snapshot
2380        .as_ref()
2381        .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
2382    let provider = msg
2383        .get("provider")
2384        .and_then(serde_json::Value::as_str)
2385        .map(str::trim)
2386        .filter(|value| !value.is_empty())
2387        .ok_or_else(|| "oauth requires a provider".to_string())?;
2388    let action = msg
2389        .get("action")
2390        .and_then(serde_json::Value::as_str)
2391        .map(str::trim)
2392        .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
2393        .ok_or_else(|| "oauth requires a valid action".to_string())?;
2394    let input = msg
2395        .get("input")
2396        .cloned()
2397        .unwrap_or_else(|| serde_json::json!({}));
2398    let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
2399        .map_err(|_| "OAuth provider request failed".to_string())?;
2400    let response = rpi_extensions::OAuthActionResponse::parse(raw)
2401        .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
2402    match response.kind {
2403        rpi_extensions::OAuthResponseKind::Credential => {
2404            let credential = response
2405                .credential()
2406                .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
2407                .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
2408            crate::config::upsert_oauth_token(
2409                provider,
2410                credential.access,
2411                credential.refresh,
2412                credential.expires_at,
2413            )
2414            .map_err(|_| "OAuth credentials could not be saved".to_string())?;
2415            Ok(serde_json::json!({
2416                "provider": provider,
2417                "status": "authenticated"
2418            }))
2419        }
2420        rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
2421            "provider": provider,
2422            "status": "interaction",
2423            "display": oauth_display(&response.payload)
2424        })),
2425        rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
2426            "provider": provider,
2427            "status": "success"
2428        })),
2429        rpi_extensions::OAuthResponseKind::Error => {
2430            Err("OAuth provider rejected the request".to_string())
2431        }
2432    }
2433}
2434
2435/// Keep provider-defined OAuth UI data deliberately narrow. In particular,
2436/// token-like fields never cross the RPC boundary, even if a plugin includes
2437/// them in an interaction payload by mistake.
2438fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
2439    let display = payload.get("display").unwrap_or(payload);
2440    let Some(object) = display.as_object() else {
2441        return serde_json::json!({});
2442    };
2443    let mut safe = serde_json::Map::new();
2444    for key in [
2445        "title",
2446        "url",
2447        "instructions",
2448        "user_code",
2449        "verification_uri",
2450    ] {
2451        if let Some(value) = object.get(key) {
2452            safe.insert(key.to_string(), value.clone());
2453        }
2454    }
2455    serde_json::Value::Object(safe)
2456}
2457
2458async fn rpc_get_provider_models(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2459    let required = |name: &str| {
2460        msg.get(name)
2461            .and_then(serde_json::Value::as_str)
2462            .map(str::trim)
2463            .filter(|value| !value.is_empty())
2464            .ok_or_else(|| format!("get_provider_models requires {name}"))
2465    };
2466    let api = required("api")?;
2467    let base_url = required("baseUrl")?;
2468    let api_key = required("apiKey")?;
2469    let models = crate::provider_probe::get_models(api, base_url, api_key).await?;
2470    Ok(serde_json::json!({"models": models}))
2471}
2472
2473async fn rpc_test_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2474    let required = |name: &str| {
2475        msg.get(name)
2476            .and_then(serde_json::Value::as_str)
2477            .map(str::trim)
2478            .filter(|value| !value.is_empty())
2479            .ok_or_else(|| format!("test_provider_config requires {name}"))
2480    };
2481    let result = crate::provider_probe::test_connection(
2482        required("api")?,
2483        required("baseUrl")?,
2484        required("apiKey")?,
2485    )
2486    .await?;
2487    Ok(serde_json::to_value(result).map_err(|error| error.to_string())?)
2488}
2489
2490fn rpc_set_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2491    let required = |name: &str| {
2492        msg.get(name)
2493            .and_then(serde_json::Value::as_str)
2494            .map(str::trim)
2495            .filter(|value| !value.is_empty())
2496            .ok_or_else(|| format!("set_provider_config requires {name}"))
2497    };
2498    let provider = required("provider")?;
2499    let api = required("api")?;
2500    let base_url = required("baseUrl")?;
2501    let api_key = required("apiKey")?;
2502    let model_id = required("modelId")?;
2503    let model_name = msg.get("modelName").and_then(serde_json::Value::as_str);
2504    let positive_integer = |name: &str| match msg.get(name) {
2505        None | Some(serde_json::Value::Null) => Ok(None),
2506        Some(value) => value
2507            .as_u64()
2508            .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
2509            .ok_or_else(|| format!("{name} must be a positive integer"))
2510            .map(Some),
2511    };
2512    let context_window = positive_integer("contextWindow")?;
2513    let max_tokens = positive_integer("maxOutputTokens")?;
2514    let supports_images = msg
2515        .get("supportsImages")
2516        .and_then(serde_json::Value::as_bool);
2517    crate::config::add_provider(
2518        provider,
2519        api,
2520        base_url,
2521        api_key,
2522        model_id,
2523        model_name,
2524        context_window,
2525        max_tokens,
2526        supports_images,
2527    )?;
2528    Ok(serde_json::json!({
2529        "provider": provider,
2530        "modelId": model_id,
2531        "api": api,
2532        "requiresRestart": true,
2533    }))
2534}
2535
2536fn rpc_update_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2537    let required = |name: &str| {
2538        msg.get(name)
2539            .and_then(serde_json::Value::as_str)
2540            .map(str::trim)
2541            .filter(|value| !value.is_empty())
2542            .ok_or_else(|| format!("update_provider_config requires {name}"))
2543    };
2544    let positive_integer = |name: &str| match msg.get(name) {
2545        None | Some(serde_json::Value::Null) => Ok(None),
2546        Some(value) => value
2547            .as_u64()
2548            .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
2549            .ok_or_else(|| format!("{name} must be a positive integer"))
2550            .map(Some),
2551    };
2552    let provider = required("provider")?;
2553    let api = required("api")?;
2554    let base_url = required("baseUrl")?;
2555    let model_id = required("modelId")?;
2556    crate::config::update_provider(
2557        provider,
2558        api,
2559        base_url,
2560        msg.get("apiKey").and_then(serde_json::Value::as_str),
2561        model_id,
2562        msg.get("modelName").and_then(serde_json::Value::as_str),
2563        positive_integer("contextWindow")?,
2564        positive_integer("maxOutputTokens")?,
2565        msg.get("supportsImages")
2566            .and_then(serde_json::Value::as_bool)
2567            .unwrap_or(false),
2568    )?;
2569    Ok(serde_json::json!({
2570        "provider": provider,
2571        "modelId": model_id,
2572        "requiresRestart": true,
2573    }))
2574}
2575
2576fn rpc_delete_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2577    let provider = msg
2578        .get("provider")
2579        .and_then(serde_json::Value::as_str)
2580        .map(str::trim)
2581        .filter(|value| !value.is_empty())
2582        .ok_or_else(|| "delete_provider_config requires provider".to_string())?;
2583    crate::config::delete_provider(provider)?;
2584    Ok(serde_json::json!({
2585        "provider": provider,
2586        "requiresRestart": true,
2587    }))
2588}
2589
2590async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2591    let provider = msg
2592        .get("provider")
2593        .and_then(serde_json::Value::as_str)
2594        .map(str::trim)
2595        .filter(|value| !value.is_empty())
2596        .ok_or_else(|| "set_api_key requires a provider".to_string())?;
2597    let api_key = msg
2598        .get("apiKey")
2599        .and_then(serde_json::Value::as_str)
2600        .map(str::trim)
2601        .filter(|value| !value.is_empty())
2602        .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
2603    crate::config::upsert_credential(
2604        provider,
2605        crate::config::Credential::ApiKey {
2606            key: Some(api_key.to_string()),
2607            env: None,
2608        },
2609    )
2610    .map_err(|error| error.to_string())?;
2611    Ok(serde_json::json!({
2612        "provider": provider,
2613        "ready": true,
2614        "source": "auth.json",
2615        "requiresRestart": true,
2616    }))
2617}
2618
2619fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
2620    let models = catalog
2621        .iter()
2622        .map(crate::rpc::protocol::model_descriptor)
2623        .collect::<Vec<_>>();
2624    serde_json::json!({"models": models})
2625}
2626
2627async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
2628    let levels = lane
2629        .get_model()
2630        .await
2631        .map(|model| model.supported_thinking_levels())
2632        .unwrap_or_default();
2633    serde_json::json!({"levels": levels})
2634}
2635
2636async fn rpc_configure_external_tools(
2637    lane: &Arc<dyn AgentLane>,
2638    harness: &AgentHarness,
2639    run_active: bool,
2640    backend: &Arc<crate::rpc::external_tools::ExternalTools>,
2641    old_names: &mut Vec<String>,
2642    msg: &serde_json::Value,
2643) -> Result<serde_json::Value, String> {
2644    use rpi_harness::types::HarnessTool;
2645    use rpi_tools::tools::external::{
2646        create_external_tool, validate_external_tool_definitions, ExternalToolDefinition,
2647    };
2648    if run_active
2649        || lane
2650            .snapshot()
2651            .await
2652            .map_err(|e| e.to_string())?
2653            .active
2654            .is_some()
2655    {
2656        return Err("cannot configure external tools while a run is active".into());
2657    }
2658    let definitions: Vec<ExternalToolDefinition> =
2659        serde_json::from_value(msg["tools"].clone()).map_err(|e| e.to_string())?;
2660    let mut registry = harness.get_tools().await.map_err(|e| e.to_string())?;
2661    registry.retain(|tool| !old_names.contains(&tool.tool.schema().name));
2662    // Reserve security/native names even when unavailable or disabled at launch.
2663    let mut reserved = [
2664        "read",
2665        "write",
2666        "edit",
2667        "bash",
2668        "powershell",
2669        "docs",
2670        "grep",
2671        "find",
2672        "ls",
2673        "image",
2674        "permissions",
2675        "permission",
2676        "ask_user",
2677        "plan_mode_start",
2678        "plan_mode_complete",
2679    ]
2680    .iter()
2681    .map(|name| name.to_string())
2682    .collect::<Vec<_>>();
2683    reserved.extend(registry.iter().map(|tool| tool.tool.schema().name.clone()));
2684    validate_external_tool_definitions(&definitions, &reserved).map_err(|e| e.to_string())?;
2685    let names = definitions
2686        .iter()
2687        .map(|def| def.name.clone())
2688        .collect::<Vec<_>>();
2689    let mut active = lane.get_active_tools().await.map_err(|e| e.to_string())?;
2690    active.retain(|name| !old_names.contains(name));
2691    active.extend(names.clone());
2692    registry.extend(
2693        definitions
2694            .into_iter()
2695            .map(|def| HarnessTool::new(create_external_tool(def, backend.clone()))),
2696    );
2697    harness
2698        .set_tools(registry, Some(active.clone()))
2699        .await
2700        .map_err(|e| e.to_string())?;
2701    *old_names = names.clone();
2702    Ok(serde_json::json!({"tools": names, "activeTools": active}))
2703}
2704
2705async fn rpc_get_available_tools(
2706    lane: &Arc<dyn AgentLane>,
2707    harness: &AgentHarness,
2708) -> serde_json::Value {
2709    let active: Vec<String> = if harness.has_exact_resources() {
2710        harness
2711            .execution_context()
2712            .ok()
2713            .and_then(|v| serde_json::from_value(v["tools"].clone()).ok())
2714            .unwrap_or_default()
2715    } else {
2716        lane.get_active_tools().await.unwrap_or_default()
2717    };
2718    let tools = harness
2719        .get_tools()
2720        .await
2721        .map(|tools| {
2722            tools
2723                .iter()
2724                .map(|tool| tool.tool.schema().name.clone())
2725                .collect::<Vec<_>>()
2726        })
2727        .unwrap_or_else(|_| active.clone());
2728    serde_json::json!({"tools": tools, "activeTools": active})
2729}
2730
2731async fn rpc_set_model(
2732    lane: &Arc<dyn AgentLane>,
2733    catalog: &[rpi_ai::Model],
2734    msg: &serde_json::Value,
2735) -> Result<serde_json::Value, String> {
2736    let id = msg
2737        .get("modelId")
2738        .or_else(|| msg.get("model"))
2739        .and_then(|v| v.as_str())
2740        .ok_or("set_model requires `modelId`")?;
2741    let provider = msg.get("provider").and_then(|v| v.as_str());
2742    let model = catalog
2743        .iter()
2744        .find(|m| {
2745            m.id.eq_ignore_ascii_case(id)
2746                && provider.map_or(true, |provider| m.provider.eq_ignore_ascii_case(provider))
2747        })
2748        .cloned()
2749        .ok_or_else(|| format!("unknown model: {id}"))?;
2750    lane.set_model(model).await.map_err(|e| e.to_string())?;
2751    Ok(serde_json::json!({"modelId": id, "provider": provider}))
2752}
2753
2754async fn rpc_set_thinking_level(
2755    lane: &Arc<dyn AgentLane>,
2756    msg: &serde_json::Value,
2757) -> Result<serde_json::Value, String> {
2758    let value = msg
2759        .get("level")
2760        .or_else(|| msg.get("thinkingLevel"))
2761        .ok_or("set_thinking_level requires `level`")?;
2762    let level: rpi_ai::types::ThinkingLevel =
2763        serde_json::from_value(value.clone()).map_err(|_| {
2764            "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
2765        })?;
2766    lane.set_thinking_level(level)
2767        .await
2768        .map_err(|e| e.to_string())?;
2769    Ok(serde_json::json!({"thinkingLevel": level}))
2770}
2771
2772async fn rpc_set_active_tools(
2773    lane: &Arc<dyn AgentLane>,
2774    msg: &serde_json::Value,
2775) -> Result<serde_json::Value, String> {
2776    let tools = msg
2777        .get("tools")
2778        .and_then(|v| v.as_array())
2779        .map(|arr| {
2780            arr.iter()
2781                .map(|v| {
2782                    v.as_str()
2783                        .map(str::to_owned)
2784                        .ok_or("tool names must be strings")
2785                })
2786                .collect::<Result<Vec<_>, _>>()
2787        })
2788        .ok_or("set_active_tools requires a `tools` array")??;
2789    lane.set_active_tools(tools.clone())
2790        .await
2791        .map_err(|e| e.to_string())?;
2792    Ok(serde_json::json!({"activeTools": tools}))
2793}
2794
2795/// Stable JSON projection for the fine-grained agent lifecycle stream.
2796///
2797/// Delegates to the shared [`crate::remote::protocol::RemoteEvent`] so the
2798/// server and the `--connect` client agree on the wire shape by construction.
2799fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
2800    crate::rpc::events::project(event)
2801}
2802
2803/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
2804/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
2805/// payload's key fields).
2806fn emit_json_event(event: &HarnessEvent) {
2807    let line = match event {
2808        HarnessEvent::CompactionProgress(e) => {
2809            serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
2810        }
2811        HarnessEvent::RunStart(e) => serde_json::json!({
2812            "type": "run_start",
2813            "lane": e.lane,
2814            "runId": e.run_id,
2815        }),
2816        HarnessEvent::RunEnd(e) => serde_json::json!({
2817            "type": "run_end",
2818            "lane": e.lane,
2819            "runId": e.run_id,
2820            "outcome": run_end_outcome_str(e.outcome),
2821            "leafId": e.leaf_id,
2822        }),
2823    };
2824    println!("{line}");
2825}
2826
2827fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
2828    match o {
2829        RunEndOutcome::Completed => "completed",
2830        RunEndOutcome::Aborted => "aborted",
2831        RunEndOutcome::Failed => "failed",
2832    }
2833}
2834
2835/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
2836///
2837/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
2838/// render streaming responses). The REPL fallback ignores it.
2839///
2840/// `model_catalog` is the resolved provider's full model list, passed through
2841/// so the TUI's `/model` selector can display available models (read-only —
2842/// v1 does not switch models mid-session; see the initial port notes (retired)).
2843pub async fn interactive(
2844    harness: &AgentHarness,
2845    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
2846    args: &Args,
2847    model_catalog: Vec<rpi_ai::Model>,
2848    initial: Option<String>,
2849    extra_messages: &[String],
2850    initial_images: Vec<ImageContent>,
2851    theme: Option<&str>,
2852    no_themes: bool,
2853    reload_context: &crate::session::ReloadContext,
2854) -> i32 {
2855    // ---- Session lifecycle (P1): BeforeTuiStart ----
2856    // `BeforeTuiStart`: extensions may prepare (connect a server, load a
2857    // resource) before the interactive UI initializes. Runs with a per-event
2858    // timeout; a handler returning `EVENT_HANDLER_ABORT` **vetoes** startup —
2859    // we print the reason and exit without entering the TUI. No-op when no
2860    // extension subscribes.
2861    if let Some(reason) = crate::session::dispatch_session_event_async(
2862        reload_context,
2863        rpi_plugin_sdk::EventTag::BeforeTuiStart,
2864    )
2865    .await
2866    {
2867        eprintln!("[rpi] startup vetoed by extension: {reason}");
2868        return crate::app::EXIT_VETOED;
2869    }
2870
2871    // Check if TUI is supported
2872    let force_tui = std::env::var("RPI_FORCE_TUI")
2873        .map(|v| v == "1")
2874        .unwrap_or(false);
2875    let code = if force_tui || crate::interactive_tui::is_tui_supported() {
2876        // Use TUI-based interactive mode
2877        crate::interactive_tui::interactive_tui(
2878            harness,
2879            event_rx,
2880            args,
2881            model_catalog,
2882            initial,
2883            extra_messages,
2884            initial_images,
2885            theme,
2886            no_themes,
2887            reload_context,
2888        )
2889        .await
2890    } else {
2891        // Fall back to simple REPL. No transcript container to hold notes, so
2892        // the startup notices print as plain lines instead.
2893        for notice in &reload_context.startup_notices {
2894            eprintln!("note: {notice}");
2895        }
2896        interactive_repl(harness, args, initial, extra_messages, initial_images).await
2897    };
2898
2899    // `SessionShutdown`: the interactive session closed (TUI/REPL returned) —
2900    // extensions release resources, disconnect, persist state. Fired after the
2901    // UI returns so it always runs (even on early-return paths inside the TUI).
2902    // A veto here is **advisory** (the session is already closing) — warn and
2903    // continue.
2904    if let Some(reason) = crate::session::dispatch_session_event_async(
2905        reload_context,
2906        rpi_plugin_sdk::EventTag::SessionShutdown,
2907    )
2908    .await
2909    {
2910        tracing::warn!(
2911            "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
2912        );
2913    }
2914
2915    code
2916}
2917
2918/// Simple REPL-based interactive mode (fallback for non-TTY environments).
2919pub async fn interactive_repl(
2920    harness: &AgentHarness,
2921    #[allow(unused_variables)] args: &Args,
2922    initial: Option<String>,
2923    extra_messages: &[String],
2924    initial_images: Vec<ImageContent>,
2925) -> i32 {
2926    // Debug: confirm we entered REPL mode
2927    let lane: Arc<dyn AgentLane> = harness.lane("main");
2928    let stdin = std::io::stdin();
2929    let is_tty = stdin.is_terminal();
2930
2931    if is_tty {
2932        println!(
2933            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
2934        );
2935    }
2936
2937    // Run the initial prompt + extra messages first (same as print mode).
2938    let mut prompts: Vec<String> = Vec::new();
2939    if let Some(init) = initial {
2940        prompts.push(init);
2941    }
2942    for m in extra_messages {
2943        prompts.push(m.clone());
2944    }
2945    let mut images = initial_images;
2946    for prompt in prompts {
2947        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
2948            return code;
2949        }
2950    }
2951
2952    // Then read lines from stdin until EOF / `/exit`.
2953    let mut line = String::new();
2954    loop {
2955        if is_tty {
2956            print!("> ");
2957            let _ = std::io::stdout().flush();
2958        }
2959        line.clear();
2960        match stdin.lock().read_line(&mut line) {
2961            Ok(0) => break, // EOF
2962            Ok(_) => {}
2963            Err(_) => break,
2964        }
2965        let trimmed = line.trim();
2966        if trimmed.is_empty() {
2967            continue;
2968        }
2969        if trimmed == "/exit" || trimmed == "/quit" {
2970            break;
2971        }
2972        if trimmed == "/abort" {
2973            let _ = lane.abort().await;
2974            eprintln!("(aborted)");
2975            continue;
2976        }
2977        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
2978            return code;
2979        }
2980    }
2981    0
2982}
2983
2984/// Run a single prompt in interactive mode, printing the assistant reply (or
2985/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
2986/// hard rejection.
2987async fn run_one(
2988    lane: &Arc<dyn AgentLane>,
2989    prompt: &str,
2990    images: Vec<ImageContent>,
2991) -> Result<(), i32> {
2992    match lane.prompt_text(prompt, images).await {
2993        Ok(result) => {
2994            match &result.outcome {
2995                HarnessRunOutcome::Completed { final_message, .. }
2996                | HarnessRunOutcome::Aborted { final_message, .. } => {
2997                    let text = assistant_text(final_message);
2998                    if !text.is_empty() {
2999                        println!("{text}");
3000                    }
3001                }
3002                HarnessRunOutcome::Failed {
3003                    error,
3004                    final_message,
3005                    ..
3006                } => {
3007                    if let Some(m) = final_message {
3008                        if let Some(em) = &m.error_message {
3009                            eprintln!("error: {em}");
3010                        }
3011                    }
3012                    eprintln!("run failed: {error:?}");
3013                }
3014                HarnessRunOutcome::Suspended { .. } => {
3015                    eprintln!("run suspended (deferred) — resume not supported in v1");
3016                }
3017            }
3018            Ok(())
3019        }
3020        Err(e) => {
3021            eprintln!("prompt rejected: {e}");
3022            Err(1)
3023        }
3024    }
3025}
3026
3027/// The print-mode (headless) message for a refused session write.
3028///
3029/// Print mode has no transcript to fall back on and no interactive `/export`
3030/// either — the process is about to exit. Naming a recovery the user cannot run
3031/// from here would be worse than saying what was lost, so this deliberately does
3032/// not recommend exporting (unlike the TUI, where `/export` does work).
3033fn session_storage_notice(message: &str) -> String {
3034    format!(
3035        "conversation not saved: {message} — the session log is missing this turn \
3036         and print mode exits without writing it"
3037    )
3038}
3039
3040#[cfg(test)]
3041#[path = "rpc/native_dispatch_tests.rs"]
3042mod native_dispatch_tests;
3043#[cfg(all(test, feature = "experimental-native-store"))]
3044#[path = "modes/native_persistence_tests.rs"]
3045mod native_persistence_tests;
3046#[cfg(test)]
3047#[path = "modes/transcript_page_tests.rs"]
3048mod transcript_page_tests;
3049
3050#[cfg(test)]
3051mod tests {
3052    use super::*;
3053    use rpi_agent::events::AgentEvent;
3054    use rpi_agent::types::AgentToolResult;
3055    use rpi_ai::types::{
3056        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
3057    };
3058    use rpi_harness::session::types::OperationError;
3059
3060    /// Regression: the abort reply must carry the reclaimed queue back instead
3061    /// of only `{aborted:true}`, and each queue must be reported separately.
3062    #[test]
3063    fn abort_response_hands_back_the_reclaimed_queues() {
3064        let user = |text: &str| {
3065            rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new(
3066                rpi_ai::types::UserContent::Text(text.to_string()),
3067                0,
3068            ))
3069        };
3070        let result = rpi_harness::agent_harness::AbortResult {
3071            run_id: "run-1".to_string(),
3072            steer: vec![user("S")],
3073            follow_up: vec![user("F")],
3074        };
3075
3076        let reply = abort_response(&result);
3077        assert_eq!(reply["aborted"], serde_json::json!(true));
3078        assert_eq!(reply["steer"][0]["text"], serde_json::json!("S"));
3079        assert_eq!(reply["followUp"][0]["text"], serde_json::json!("F"));
3080        // Steering must not absorb the follow-up entry.
3081        assert_eq!(reply["steer"].as_array().unwrap().len(), 1);
3082
3083        // The wrapper-cancellation path reports the same shape.
3084        assert_eq!(
3085            reclaimed_payload(&result),
3086            serde_json::json!({
3087                "steer": reply["steer"].clone(),
3088                "followUp": reply["followUp"].clone(),
3089            })
3090        );
3091    }
3092
3093    include!("rpc/external_tools_configuration_tests.rs");
3094    include!("rpc/external_tools_lifecycle_tests.rs");
3095    include!("rpc/external_tools_abort_outcome_tests.rs");
3096
3097    #[test]
3098    fn session_branch_query_requires_canonical_arguments() {
3099        let query = branch_entry_query(&serde_json::json!({
3100            "customTypes": ["alpha", "beta", "alpha"], "afterSeq": 42,
3101        }))
3102        .unwrap();
3103        assert_eq!(query.custom_types, ["alpha", "beta"]);
3104        assert_eq!(query.order, EntryOrder::OldestFirst);
3105        assert_eq!(query.after_seq, Some(42));
3106        assert!(query.entry_queries().iter().all(|q| q.limit.is_none()));
3107        for invalid in [
3108            serde_json::json!({}),
3109            serde_json::json!(null),
3110            serde_json::json!([]),
3111            serde_json::json!({"customTypes": []}),
3112            serde_json::json!({"customTypes": "alpha"}),
3113            serde_json::json!({"customTypes": [""]}),
3114            serde_json::json!({"customTypes": [" \t "]}),
3115            serde_json::json!({"customTypes": ["alpha", 1]}),
3116            serde_json::json!({"customTypes": ["alpha"], "limit": 0}),
3117            serde_json::json!({"customTypes": ["alpha"], "limit": 1.5}),
3118            serde_json::json!({"customTypes": ["alpha"], "order": "sideways"}),
3119            serde_json::json!({"customTypes": ["alpha"], "order": 1}),
3120            serde_json::json!({"customTypes": ["alpha"], "afterSeq": -1}),
3121        ] {
3122            assert!(branch_entry_query(&invalid).is_err(), "{invalid}");
3123        }
3124    }
3125
3126    #[test]
3127    fn session_branch_multi_type_limit_is_pushed_to_each_storage_query() {
3128        for order in ["oldestFirst", "newestFirst"] {
3129            let request = branch_entry_query(&serde_json::json!({
3130                "customTypes": ["alpha", "beta", "alpha"],
3131                "limit": 2, "order": order, "afterSeq": 42,
3132            }))
3133            .unwrap();
3134            let queries = request.entry_queries();
3135            assert_eq!(queries.len(), 2);
3136            for query in queries {
3137                assert_eq!(query.limit, Some(2));
3138                assert_eq!(query.order, Some(request.order));
3139                assert_eq!(query.cursor, Some(EntryCursor { after_seq: 42 }));
3140            }
3141        }
3142    }
3143
3144    #[tokio::test]
3145    async fn resource_selection_binding_is_protected_for_scoped_and_unscoped_public_appends() {
3146        use rpi_extensions::RuntimeActionHost;
3147        let harness = AgentHarness::create(Default::default()).await.unwrap();
3148        let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3149            vec![],
3150            std::env::temp_dir(),
3151            tokio::runtime::Handle::current(),
3152            Default::default(),
3153            rpi_extensions::ToolCallContext::default(),
3154        );
3155        crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3156        let (_, session_id, leaf_id) = branch_snapshot(
3157            &harness,
3158            &branch_entry_query(&serde_json::json!({"customTypes":["rpi.value"]})).unwrap(),
3159        )
3160        .await
3161        .unwrap();
3162        let guard = serde_json::json!({
3163            "sessionId": session_id, "leafId": leaf_id,
3164            "customType": "rpi.value", "entryId": null
3165        });
3166        for key in ["rpi.resource-selection.v1", "rpi.resource-inventory.v1"] {
3167            for value in [serde_json::Value::Null, serde_json::json!({"forged":true})] {
3168                for scoped in [false, true] {
3169                    let mut request = serde_json::json!({
3170                        "customType":"rpi.value", "data":{"key":key,"value":value}
3171                    });
3172                    if scoped {
3173                        request["expected"] = guard.clone();
3174                    }
3175                    assert!(rpc_append_entry(&harness, &request)
3176                        .await
3177                        .unwrap_err()
3178                        .contains("host-owned"));
3179                    assert!(host
3180                        .append_entry(request)
3181                        .await
3182                        .unwrap_err()
3183                        .contains("host-owned"));
3184                }
3185            }
3186        }
3187        let request = serde_json::json!({
3188            "customType":"rpi.value", "data":{"key":"user.preference","value":1},
3189            "expected":guard
3190        });
3191        assert_eq!(host.append_entry(request).await.unwrap()["scoped"], true);
3192    }
3193
3194    #[tokio::test]
3195    async fn branch_snapshot_guards_and_outbox_acknowledgements_cross_the_runtime_action_boundary()
3196    {
3197        use rpi_extensions::RuntimeActionHost;
3198        let harness = AgentHarness::create(Default::default()).await.unwrap();
3199        let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3200            vec![],
3201            std::env::temp_dir(),
3202            tokio::runtime::Handle::current(),
3203            Default::default(),
3204            rpi_extensions::ToolCallContext::default(),
3205        );
3206        crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3207        // Preview's CAS API must not bypass main's host-owned binding guard.
3208        let before = harness
3209            .session()
3210            .find_entries(&EntryQuery::default())
3211            .await
3212            .unwrap();
3213        let values = host
3214            .get_session_branch(serde_json::json!({"customTypes":["rpi.value"]}))
3215            .await
3216            .unwrap();
3217        for key in ["rpi.resource-selection.v1", "rpi.resource-inventory.v1"] {
3218            let forged = serde_json::json!({
3219                "customType":"rpi.value", "data":{"key":key,"value":{}},
3220                "expected":{"sessionId":values["sessionId"],"leafId":values["leafId"],"customType":"rpi.value","entryId":null}
3221            });
3222            assert!(host
3223                .append_entry(forged.clone())
3224                .await
3225                .unwrap_err()
3226                .contains("host-owned"));
3227            assert!(rpc_append_entry(&harness, &forged)
3228                .await
3229                .unwrap_err()
3230                .contains("host-owned"));
3231        }
3232        assert_eq!(
3233            harness
3234                .session()
3235                .find_entries(&EntryQuery::default())
3236                .await
3237                .unwrap(),
3238            before
3239        );
3240        let snapshot = host
3241            .get_session_branch(
3242                serde_json::json!({"customTypes":["goal-state"],"limit":1,"order":"newestFirst"}),
3243            )
3244            .await
3245            .unwrap();
3246        let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":null});
3247        let mutation =
3248            serde_json::json!({"customType":"goal-state","data":{"goal":"A"},"expected":expected});
3249        let ack = host.append_entry(mutation.clone()).await.unwrap();
3250        assert_eq!(ack["scoped"], true);
3251        assert!(host.append_entry(mutation.clone()).await.is_err());
3252        assert!(rpc_append_entry(&harness, &mutation).await.is_err());
3253        for _ in 0..2 {
3254            let snapshot = host.get_session_branch(serde_json::json!({"customTypes":["goal-state"],"limit":1,"order":"newestFirst"})).await.unwrap();
3255            let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":ack["entryId"]});
3256            let notice = host.append_entry(serde_json::json!({"expected":expected,"outboxKey":"r1","message":{
3257                "kind":"custom","role":"custom","content":[{"type":"text","text":"Goal A"}],"data":{"customType":"goal"},"timestamp":1
3258            }})).await.unwrap();
3259            assert_eq!(notice["scoped"], true);
3260        }
3261        let entries = harness
3262            .session()
3263            .find_entries(&EntryQuery {
3264                outbox_key: Some("r1".into()),
3265                ..Default::default()
3266            })
3267            .await
3268            .unwrap();
3269        assert_eq!(entries.len(), 1);
3270        let snapshot = host
3271            .get_session_branch(serde_json::json!({"customTypes":["goal-state"],"limit":1}))
3272            .await
3273            .unwrap();
3274        let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":ack["entryId"]});
3275        let rpc_ack = rpc_append_entry(
3276            &harness,
3277            &serde_json::json!({"customType":"goal-state","data":{"goal":"B"},"expected":expected}),
3278        )
3279        .await
3280        .unwrap();
3281        assert_eq!(rpc_ack["scoped"], true);
3282        assert!(rpc_ack["entryId"].as_str().is_some_and(|id| !id.is_empty()));
3283    }
3284
3285    #[tokio::test]
3286    async fn session_branch_exact_type_identities_round_trip_rpc_and_actions() {
3287        use rpi_extensions::RuntimeActionHost;
3288        let harness = AgentHarness::create(Default::default()).await.unwrap();
3289        let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3290            vec![],
3291            std::env::temp_dir(),
3292            tokio::runtime::Handle::current(),
3293            Default::default(),
3294            rpi_extensions::ToolCallContext::default(),
3295        );
3296        crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3297        let names = ["alpha", " alpha ", "beta", " beta "];
3298        for (index, name) in names.iter().enumerate() {
3299            let args = serde_json::json!({"customType": name, "data": {"n": index}});
3300            if index % 2 == 0 {
3301                host.append_entry(args).await.unwrap();
3302            } else {
3303                rpc_append_entry(&harness, &args).await.unwrap();
3304            }
3305        }
3306        let request = branch_entry_query(&serde_json::json!({
3307            "customTypes": ["alpha", " alpha ", "beta", " beta ", "alpha"],
3308        }))
3309        .unwrap();
3310        assert_eq!(request.custom_types, names);
3311        let mut input = String::new();
3312        let mut expected = Vec::new();
3313        for (index, name) in names.iter().enumerate() {
3314            let args = serde_json::json!({"customTypes": [name]});
3315            let result = host.get_session_branch(args.clone()).await.unwrap();
3316            assert_eq!(result["count"], 1);
3317            assert_eq!(result["entries"][0]["customType"], *name);
3318            assert_eq!(result["entries"][0]["data"]["n"], index);
3319            let mut envelope = args;
3320            envelope["type"] = serde_json::json!("get_session_branch");
3321            envelope["id"] = serde_json::json!(index);
3322            input.push_str(&format!("{envelope}\n"));
3323            expected.push(result);
3324        }
3325        let output = RpcOutput::default();
3326        assert_eq!(
3327            rpc_with_io(
3328                &harness,
3329                None,
3330                vec![],
3331                rpi_extensions::UiDialogMailbox::new(),
3332                None,
3333                crate::args::RpcMessageUpdates::default(),
3334                tokio::io::BufReader::new(input.as_bytes()),
3335                output.clone(),
3336            )
3337            .await,
3338            0
3339        );
3340        let messages = output.messages();
3341        for (index, expected) in expected.into_iter().enumerate() {
3342            let response = messages
3343                .iter()
3344                .find(|message| message["id"] == index)
3345                .unwrap();
3346            assert_eq!(response["data"], expected);
3347        }
3348    }
3349
3350    #[tokio::test]
3351    async fn session_branch_filters_merge_and_respect_fork_bounds() {
3352        use rpi_harness::session::types::{ForkOptions, ForkPosition, SessionRepo};
3353        use rpi_harness::session::{CounterIdGenerator, FakeClock, InMemorySessionRepo, Session};
3354        let repo = InMemorySessionRepo::new(
3355            Arc::new(FakeClock::new()),
3356            Arc::new(CounterIdGenerator::new()),
3357        );
3358        let storage = repo.create(&Default::default()).await.unwrap();
3359        let session = Session::new(Arc::new(storage), None);
3360        let harness = AgentHarness::create(rpi_harness::types::AgentHarnessOptions {
3361            session,
3362            ..Default::default()
3363        })
3364        .await
3365        .unwrap();
3366        let session = harness.session();
3367        let empty = rpc_get_session_branch(
3368            &harness,
3369            &serde_json::json!({"customTypes":["alpha","beta"]}),
3370        )
3371        .await
3372        .unwrap();
3373        assert_eq!(empty["entries"], serde_json::json!([]));
3374        assert_eq!(empty["filtered"], true);
3375        let mut ids = Vec::new();
3376        let mut fork_target = None;
3377        for (index, kind) in ["alpha", "noise", "beta", "alpha", "noise", "beta"]
3378            .into_iter()
3379            .enumerate()
3380        {
3381            if index == 2 {
3382                fork_target = Some(
3383                    session
3384                        .append_message(rpi_agent::AgentMessage::User(UserMessage::new(
3385                            "fork target",
3386                            0,
3387                        )))
3388                        .await
3389                        .unwrap(),
3390                );
3391            }
3392            ids.push(session.append_custom_entry(kind, None).await.unwrap());
3393        }
3394        // Neither ordinary messages nor durable lane records are custom entries.
3395        session
3396            .append_message(rpi_agent::AgentMessage::User(UserMessage::new("noise", 0)))
3397            .await
3398            .unwrap();
3399        session
3400            .append_record(
3401                serde_json::from_value(serde_json::json!({
3402                    "type":"usage", "id":"usage-noise", "seq":0, "timestamp":0,
3403                    "lane":"main", "usage":Usage::zero(), "cause":"adjustment"
3404                }))
3405                .unwrap(),
3406            )
3407            .await
3408            .unwrap();
3409        let run = |args| {
3410            let harness = &harness;
3411            async move { rpc_get_session_branch(harness, &args).await.unwrap() }
3412        };
3413        let all = run(serde_json::json!({"customTypes":["alpha","beta","alpha"]})).await;
3414        let entry_ids = |value: &serde_json::Value| -> Vec<String> {
3415            value["entries"]
3416                .as_array()
3417                .unwrap()
3418                .iter()
3419                .map(|entry| entry["id"].as_str().unwrap().to_owned())
3420                .collect()
3421        };
3422        assert_eq!(
3423            entry_ids(&all),
3424            vec![
3425                ids[0].clone(),
3426                ids[2].clone(),
3427                ids[3].clone(),
3428                ids[5].clone()
3429            ]
3430        );
3431        assert_eq!(all["count"], 4);
3432        assert_eq!(all["filtered"], true);
3433        let cursor = all["entries"][1]["seq"].as_u64().unwrap();
3434        for (order, expected) in [
3435            (
3436                "oldestFirst",
3437                vec![ids[0].clone(), ids[2].clone(), ids[3].clone()],
3438            ),
3439            (
3440                "newestFirst",
3441                vec![ids[5].clone(), ids[3].clone(), ids[2].clone()],
3442            ),
3443        ] {
3444            let result =
3445                run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "limit":3}))
3446                    .await;
3447            assert_eq!(entry_ids(&result), expected);
3448            let result = run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "afterSeq":cursor, "limit":1})).await;
3449            assert_eq!(
3450                entry_ids(&result),
3451                vec![if order == "oldestFirst" {
3452                    ids[3].clone()
3453                } else {
3454                    ids[0].clone()
3455                }]
3456            );
3457        }
3458        let single = run(serde_json::json!({"customTypes":["beta"], "afterSeq":all["entries"][3]["seq"], "order":"newestFirst", "limit":1})).await;
3459        assert_eq!(entry_ids(&single), vec![ids[2].clone()]);
3460
3461        // Rewind and append: old descendants and other lanes must not leak in.
3462        session.move_lane("main", Some(&ids[2])).await.unwrap();
3463        let branch_tip = session.append_custom_entry("alpha", None).await.unwrap();
3464        session.create_lane("other", Some(&ids[5])).await.unwrap();
3465        let branch = run(serde_json::json!({"customTypes":["alpha","beta"]})).await;
3466        assert_eq!(
3467            entry_ids(&branch),
3468            vec![ids[0].clone(), ids[2].clone(), branch_tip]
3469        );
3470        let empty =
3471            run(serde_json::json!({"customTypes":["alpha","beta"], "afterSeq":u64::MAX - 1})).await;
3472        assert!(entry_ids(&empty).is_empty());
3473
3474        // Fork before the message preceding beta; custom entries remain covered.
3475        let fork = repo
3476            .fork(
3477                &session.storage().metadata(),
3478                &Default::default(),
3479                &ForkOptions::Branch {
3480                    entry_id: fork_target,
3481                    position: Some(ForkPosition::Before),
3482                },
3483            )
3484            .await
3485            .unwrap();
3486        harness
3487            .set_session(Session::new(Arc::new(fork), None))
3488            .await
3489            .unwrap();
3490        let forked = run(
3491            serde_json::json!({"customTypes":["alpha","beta"], "order":"newestFirst", "limit":2}),
3492        )
3493        .await;
3494        assert_eq!(entry_ids(&forked), vec![ids[0].clone()]);
3495    }
3496
3497    #[tokio::test]
3498    async fn session_branch_rpc_and_action_share_strict_query_behavior() {
3499        use rpi_extensions::RuntimeActionHost;
3500        let harness = AgentHarness::create(Default::default()).await.unwrap();
3501        let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3502            vec![],
3503            std::env::temp_dir(),
3504            tokio::runtime::Handle::current(),
3505            Default::default(),
3506            rpi_extensions::ToolCallContext::default(),
3507        );
3508        crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3509        // AppendEntry deliberately keeps its distinct singular write protocol.
3510        host.append_entry(serde_json::json!({"customType":"alpha", "data":{"n":1}}))
3511            .await
3512            .unwrap();
3513        rpc_append_entry(
3514            &harness,
3515            &serde_json::json!({"customType":"beta", "data":{"n":2}}),
3516        )
3517        .await
3518        .unwrap();
3519        let valid =
3520            serde_json::json!({"customTypes":["alpha","beta"], "limit":1, "order":"newestFirst"});
3521        let expected = host.get_session_branch(valid.clone()).await.unwrap();
3522        assert_eq!(expected["entries"][0]["customType"], "beta");
3523        let mut requests = vec![serde_json::json!({})];
3524        for key in [
3525            "customType",
3526            "custom_type",
3527            "custom_types",
3528            "after_seq",
3529            "unknown",
3530        ] {
3531            let value = if key == "after_seq" {
3532                serde_json::json!(0)
3533            } else {
3534                serde_json::json!("alpha")
3535            };
3536            requests.push(serde_json::json!({key:value.clone()}));
3537            let mut mixed = valid.clone();
3538            mixed[key] = value;
3539            requests.push(mixed);
3540        }
3541        let mut input = String::new();
3542        let mut envelope = valid;
3543        envelope["type"] = serde_json::json!("get_session_branch");
3544        envelope["id"] = serde_json::json!("modern");
3545        input.push_str(&format!("{envelope}\n"));
3546        for (index, args) in requests.iter().enumerate() {
3547            let error = host.get_session_branch(args.clone()).await.unwrap_err();
3548            assert!(error.contains("get_session_branch"), "{error}");
3549            let mut envelope = args.clone();
3550            envelope["type"] = serde_json::json!("get_session_branch");
3551            envelope["id"] = serde_json::json!(index);
3552            assert_eq!(
3553                rpc_get_session_branch(&harness, &envelope)
3554                    .await
3555                    .unwrap_err(),
3556                error
3557            );
3558            input.push_str(&format!("{envelope}\n"));
3559        }
3560        let output = RpcOutput::default();
3561        assert_eq!(
3562            rpc_with_io(
3563                &harness,
3564                None,
3565                vec![],
3566                rpi_extensions::UiDialogMailbox::new(),
3567                None,
3568                crate::args::RpcMessageUpdates::default(),
3569                tokio::io::BufReader::new(input.as_bytes()),
3570                output.clone(),
3571            )
3572            .await,
3573            0
3574        );
3575        let messages = output.messages();
3576        assert_eq!(
3577            messages.iter().find(|m| m["id"] == "modern").unwrap()["data"],
3578            expected
3579        );
3580        for index in 0..requests.len() {
3581            let response = messages.iter().find(|m| m["id"] == index).unwrap();
3582            assert_eq!(response["success"], false, "{response}");
3583        }
3584    }
3585
3586    #[tokio::test]
3587    async fn rpc_commands_list_and_execute_registered_handler() {
3588        extern "C" fn echo(
3589            input: rpi_plugin_sdk::StbStringRef,
3590            out: *mut rpi_plugin_sdk::StbString,
3591            _: *mut std::ffi::c_void,
3592        ) -> i32 {
3593            // SAFETY: the RPC invoker provides live input and output pointers.
3594            unsafe {
3595                *out = rpi_plugin_sdk::StbString::from_string(input.as_str().to_owned());
3596            }
3597            0
3598        }
3599        let mut registry = rpi_extensions::ExtensionRegistry::new();
3600        registry.register_command(
3601            "echo".into(),
3602            "Echo arguments".into(),
3603            echo,
3604            std::ptr::null_mut(),
3605        );
3606        let harness = AgentHarness::create(Default::default()).await.unwrap();
3607        let output = RpcOutput::default();
3608        let input = concat!(
3609            "{\"id\":1,\"type\":\"get_commands\"}\n",
3610            "{\"id\":2,\"type\":\"execute_command\",\"command\":\"/echo\",\"args\":\"hello world\"}\n",
3611            "{\"id\":3,\"type\":\"execute_command\",\"command\":\"missing\"}\n",
3612            "{\"id\":4,\"type\":\"execute_command\",\"command\":\"echo\",\"args\":42}\n"
3613        );
3614        assert_eq!(
3615            rpc_with_io(
3616                &harness,
3617                None,
3618                vec![],
3619                rpi_extensions::UiDialogMailbox::new(),
3620                Some(Arc::new(registry.snapshot())),
3621                crate::args::RpcMessageUpdates::default(),
3622                tokio::io::BufReader::new(input.as_bytes()),
3623                output.clone()
3624            )
3625            .await,
3626            0
3627        );
3628        let messages = output.messages();
3629        let response = |id| messages.iter().find(|m| m["id"] == id).unwrap();
3630        assert_eq!(response(1)["data"]["commands"][0]["name"], "echo");
3631        assert_eq!(
3632            response(2)["data"],
3633            serde_json::json!({"command":"/echo", "args":"hello world"})
3634        );
3635        assert_eq!(response(3)["success"], false);
3636        assert_eq!(response(4)["success"], false);
3637        assert_eq!(rpc_get_commands(None), serde_json::json!({"commands":[]}));
3638    }
3639
3640    #[test]
3641    fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
3642        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3643        struct RestoreConfig(Option<std::ffi::OsString>);
3644        impl Drop for RestoreConfig {
3645            fn drop(&mut self) {
3646                match self.0.take() {
3647                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3648                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3649                }
3650            }
3651        }
3652        let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3653        let directory = tempfile::tempdir().unwrap();
3654        std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
3655        std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
3656            "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
3657            "expired-account": {"api":"openai-completions", "models":[]}
3658        }}).to_string()).unwrap();
3659        std::fs::write(directory.path().join("auth.json"), serde_json::json!({
3660            "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
3661        }).to_string()).unwrap();
3662        let status = rpc_get_auth_status(&[], None);
3663        let providers = status["providers"].as_array().unwrap();
3664        let header = providers
3665            .iter()
3666            .find(|row| row["provider"] == "header-gateway")
3667            .unwrap();
3668        assert_eq!(header["ready"], true);
3669        assert_eq!(header["source"], "headers");
3670        assert_eq!(header["status"], "ready");
3671        let expired = providers
3672            .iter()
3673            .find(|row| row["provider"] == "expired-account")
3674            .unwrap();
3675        assert_eq!(expired["ready"], false);
3676        assert_eq!(expired["status"], "expired");
3677        assert!(!status.to_string().contains("secret"));
3678    }
3679
3680    #[test]
3681    fn oauth_display_drops_token_like_fields() {
3682        let display = oauth_display(&serde_json::json!({
3683            "display": {
3684                "title": "Sign in",
3685                "url": "https://example.test/authorize",
3686                "instructions": ["Paste the code"],
3687                "access_token": "secret-access",
3688                "refresh_token": "secret-refresh",
3689            }
3690        }));
3691        assert_eq!(display["title"], "Sign in");
3692        assert!(display.get("access_token").is_none());
3693        assert!(display.get("refresh_token").is_none());
3694    }
3695
3696    #[test]
3697    fn rpc_message_text_joins_content_blocks_with_real_newlines() {
3698        let text = rpc_message_text(&serde_json::json!({
3699            "content": [
3700                {"type": "text", "text": "first"},
3701                {"type": "text", "text": "second"}
3702            ]
3703        }));
3704        assert_eq!(text, "first\nsecond");
3705        assert!(!text.contains("\\n"));
3706    }
3707
3708    #[derive(Clone, Default)]
3709    pub(super) struct RpcOutput(Arc<Mutex<Vec<u8>>>);
3710
3711    impl Write for RpcOutput {
3712        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
3713            self.0.lock().unwrap().extend_from_slice(bytes);
3714            Ok(bytes.len())
3715        }
3716        fn flush(&mut self) -> std::io::Result<()> {
3717            Ok(())
3718        }
3719    }
3720
3721    impl RpcOutput {
3722        pub(super) fn messages(&self) -> Vec<serde_json::Value> {
3723            String::from_utf8(self.0.lock().unwrap().clone())
3724                .unwrap()
3725                .lines()
3726                .map(|line| serde_json::from_str(line).unwrap())
3727                .collect()
3728        }
3729
3730        pub(super) async fn wait_for(
3731            &self,
3732            predicate: impl Fn(&serde_json::Value) -> bool,
3733        ) -> serde_json::Value {
3734            tokio::time::timeout(std::time::Duration::from_secs(3), async {
3735                loop {
3736                    if let Some(message) = self.messages().into_iter().find(&predicate) {
3737                        return message;
3738                    }
3739                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
3740                }
3741            })
3742            .await
3743            .expect("RPC message not received")
3744        }
3745    }
3746
3747    pub(super) async fn rpc_test_harness(
3748        text: &str,
3749        tokens_per_second: f64,
3750    ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
3751        let provider = rpi_ai::providers::faux::FauxProvider::new(
3752            rpi_ai::providers::faux::FauxScript::new()
3753                .with_text(text)
3754                .with_tokens_per_second(tokens_per_second),
3755        );
3756        let options = rpi_harness::types::AgentHarnessOptions {
3757            model: provider.default_model().clone(),
3758            models: vec![provider.clone()],
3759            ..Default::default()
3760        };
3761        (AgentHarness::create(options).await.unwrap(), provider)
3762    }
3763
3764    pub(super) fn start_test_rpc(
3765        harness: AgentHarness,
3766        ui: rpi_extensions::UiDialogMailbox,
3767    ) -> (
3768        tokio::io::DuplexStream,
3769        RpcOutput,
3770        tokio::task::JoinHandle<i32>,
3771    ) {
3772        let (client, input) = tokio::io::duplex(4096);
3773        let output = RpcOutput::default();
3774        let captured = output.clone();
3775        let task = tokio::spawn(async move {
3776            rpc_with_io(
3777                &harness,
3778                None,
3779                vec![],
3780                ui,
3781                None,
3782                crate::args::RpcMessageUpdates::default(),
3783                tokio::io::BufReader::new(input),
3784                captured,
3785            )
3786            .await
3787        });
3788        (client, output, task)
3789    }
3790
3791    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3792    async fn rpc_paginated_user_summaries_and_context_windows_are_read_only() {
3793        use tokio::io::AsyncWriteExt;
3794        let (harness, _unused_provider) = rpc_test_harness("must not be called", 0.0).await;
3795        let view = harness.view();
3796        for index in 0..23 {
3797            view.append_message(
3798                rpi_ai::UserMessage::new(format!("Question {index}"), index).into(),
3799            )
3800            .await
3801            .unwrap();
3802        }
3803        let pinned = view.get_leaf_id().await.unwrap();
3804        let (mut client, output, task) =
3805            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3806        let ready = output.wait_for(|frame| frame["type"] == "ready").await;
3807        for command in ["get_message_summaries", "get_message_window"] {
3808            assert!(ready["supportedCommands"]
3809                .as_array()
3810                .unwrap()
3811                .contains(&serde_json::json!(command)));
3812        }
3813        client
3814            .write_all(b"{\"type\":\"get_message_summaries\",\"id\":1,\"limit\":10}\n")
3815            .await
3816            .unwrap();
3817        let first = output.wait_for(|frame| frame["id"] == 1).await;
3818        assert_eq!(first["success"], true);
3819        assert_eq!(first["data"]["messages"][0]["preview"], "Question 13");
3820        assert_eq!(first["data"]["messages"].as_array().unwrap().len(), 10);
3821        for (id, command, cursor, value) in [
3822            (
3823                2,
3824                "get_message_summaries",
3825                "beforeId",
3826                first["data"]["nextBeforeId"].clone(),
3827            ),
3828            (
3829                3,
3830                "get_message_window",
3831                "anchorId",
3832                first["data"]["firstId"].clone(),
3833            ),
3834        ] {
3835            let mut request = serde_json::json!({"id":id,"type":command,"limit":4,"leafId":pinned});
3836            request[cursor] = value;
3837            client
3838                .write_all(format!("{request}\n").as_bytes())
3839                .await
3840                .unwrap();
3841            let page = output.wait_for(|frame| frame["id"] == id).await;
3842            assert_eq!(page["success"], true);
3843            assert_eq!(page["data"]["messages"].as_array().unwrap().len(), 4);
3844            assert_eq!(page["data"]["leafId"], serde_json::json!(pinned));
3845        }
3846        let context = output.wait_for(|frame| frame["id"] == 3).await;
3847        for (id, cursor, key) in [
3848            (4, "beforeId", "nextBeforeId"),
3849            (5, "afterId", "nextAfterId"),
3850        ] {
3851            let mut request =
3852                serde_json::json!({"type":"get_message_window", "id":id,"leafId":pinned,"limit":4});
3853            request[cursor] = context["data"][key].clone();
3854            client
3855                .write_all(format!("{request}\n").as_bytes())
3856                .await
3857                .unwrap();
3858            let response = output.wait_for(|frame| frame["id"] == id).await;
3859            assert_eq!(response["success"], true);
3860            assert_eq!(response["data"]["messages"].as_array().unwrap().len(), 4);
3861        }
3862        client
3863            .write_all(b"{\"type\":\"stop\",\"id\":6}\n")
3864            .await
3865            .unwrap();
3866        assert_eq!(task.await.unwrap(), 0);
3867        assert_eq!(view.get_leaf_id().await.unwrap(), pinned);
3868        assert!(!output
3869            .messages()
3870            .iter()
3871            .any(|frame| frame["type"] == "agent_start"));
3872    }
3873
3874    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3875    async fn rpc_eof_waits_for_accepted_prompt() {
3876        use tokio::io::AsyncWriteExt;
3877        let (harness, _) = rpc_test_harness("completed", 100.0).await;
3878        let ui = rpi_extensions::UiDialogMailbox::new();
3879        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
3880        client
3881            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3882            .await
3883            .unwrap();
3884        client.shutdown().await.unwrap();
3885        assert_eq!(
3886            tokio::time::timeout(std::time::Duration::from_secs(3), task)
3887                .await
3888                .unwrap()
3889                .unwrap(),
3890            0
3891        );
3892        let response = output
3893            .messages()
3894            .into_iter()
3895            .find(|m| m["id"] == 1)
3896            .unwrap();
3897        assert_eq!(response["command"], "prompt");
3898        assert_eq!(response["data"]["accepted"], true);
3899        assert!(output.messages().iter().any(|message| {
3900            message["type"] == "agent_settled" && message["outcome"] == "completed"
3901        }));
3902        assert!(!ui.is_attached());
3903    }
3904
3905    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3906    async fn rpc_append_entry_publishes_generic_commit_event() {
3907        use tokio::io::AsyncWriteExt;
3908        let (harness, _) = rpc_test_harness("unused", 0.0).await;
3909        let (mut client, output, task) =
3910            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3911        client
3912            .write_all(
3913                br#"{"type":"append_entry","id":1,"customType":"durable-state","data":{"revision":1}}
3914"#,
3915            )
3916            .await
3917            .unwrap();
3918
3919        let event = output
3920            .wait_for(|message| message["type"] == "session_entry_committed")
3921            .await;
3922        assert_eq!(event["customType"], "durable-state");
3923        assert_eq!(event["entryType"], "custom");
3924        assert_eq!(event["lane"], "main");
3925        assert!(event["entryId"].as_str().is_some());
3926
3927        client
3928            .write_all(
3929                br#"{"type":"stop","id":2}
3930"#,
3931            )
3932            .await
3933            .unwrap();
3934        assert_eq!(task.await.unwrap(), 0);
3935    }
3936
3937    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3938    async fn rpc_prompt_publishes_committed_message_event() {
3939        use tokio::io::AsyncWriteExt;
3940        let (harness, _) = rpc_test_harness("completed", 0.0).await;
3941        let (mut client, output, task) =
3942            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3943        client
3944            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3945            .await
3946            .unwrap();
3947        let event = output
3948            .wait_for(|message| {
3949                message["type"] == "session_entry_committed" && message["entryType"] == "message"
3950            })
3951            .await;
3952        assert_eq!(event["lane"], "main");
3953        assert!(event["entryId"].as_str().is_some());
3954        output
3955            .wait_for(|message| message["type"] == "agent_settled")
3956            .await;
3957        client
3958            .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
3959            .await
3960            .unwrap();
3961        assert_eq!(task.await.unwrap(), 0);
3962    }
3963
3964    #[tokio::test]
3965    async fn rpc_stop_flushes_all_accepted_entry_invalidations() {
3966        let (harness, _) = rpc_test_harness("unused", 0.0).await;
3967        let output = RpcOutput::default();
3968        let mut input = String::new();
3969        for id in 1..=4 {
3970            input.push_str(&format!(
3971                "{{\"type\":\"append_entry\",\"id\":{id},\"customType\":\"durable-state\"}}\n"
3972            ));
3973        }
3974        input.push_str("{\"type\":\"stop\",\"id\":5}\n");
3975        let result = rpc_with_io(
3976            &harness,
3977            None,
3978            vec![],
3979            rpi_extensions::UiDialogMailbox::new(),
3980            None,
3981            crate::args::RpcMessageUpdates::default(),
3982            tokio::io::BufReader::new(input.as_bytes()),
3983            output.clone(),
3984        )
3985        .await;
3986        assert_eq!(result, 0);
3987        let messages = output.messages();
3988        let commits: std::collections::HashSet<_> = messages
3989            .iter()
3990            .filter(|m| m["type"] == "session_entry_committed")
3991            .map(|m| m["entryId"].as_str().unwrap())
3992            .collect();
3993        assert_eq!(
3994            commits.len(),
3995            4,
3996            "stop must flush every committed entry: {messages:?}"
3997        );
3998    }
3999
4000    #[tokio::test]
4001    async fn rpc_eof_flushes_queued_entry_invalidations() {
4002        let (harness, _) = rpc_test_harness("unused", 0.0).await;
4003        let output = RpcOutput::default();
4004        let input = b"{\"type\":\"append_entry\",\"id\":1,\"customType\":\"durable-state\",\"data\":{\"revision\":1}}\n";
4005        let result = rpc_with_io(
4006            &harness,
4007            None,
4008            vec![],
4009            rpi_extensions::UiDialogMailbox::new(),
4010            None,
4011            crate::args::RpcMessageUpdates::default(),
4012            tokio::io::BufReader::new(&input[..]),
4013            output.clone(),
4014        )
4015        .await;
4016        assert_eq!(result, 0);
4017        let messages = output.messages();
4018        assert!(
4019            messages
4020                .iter()
4021                .any(|m| m["type"] == "response" && m["id"] == 1 && m["success"] == true),
4022            "append succeeded: {messages:?}"
4023        );
4024        assert!(
4025            messages
4026                .iter()
4027                .any(|m| m["type"] == "session_entry_committed"),
4028            "EOF must flush the queued commit event: {messages:?}"
4029        );
4030    }
4031
4032    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4033    async fn rpc_compaction_settlement_identifies_its_durable_operation() {
4034        let (harness, _) = rpc_test_harness("unused", 0.0).await;
4035        let output = RpcOutput::default();
4036        let code = rpc_with_io(
4037            &harness,
4038            None,
4039            vec![],
4040            rpi_extensions::UiDialogMailbox::new(),
4041            None,
4042            crate::args::RpcMessageUpdates::default(),
4043            tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
4044            output.clone(),
4045        )
4046        .await;
4047        assert_eq!(code, 0);
4048        let messages = output.messages();
4049        let settled = messages
4050            .iter()
4051            .find(|message| message["type"] == "agent_settled")
4052            .unwrap();
4053        assert_eq!(settled["outcome"], "declined");
4054        let run_id = settled["runId"]
4055            .as_str()
4056            .expect("compaction settlement needs its durable run id");
4057        let records = harness
4058            .session()
4059            .find_records(&Default::default())
4060            .await
4061            .unwrap();
4062        assert!(records.iter().any(|record| matches!(record,
4063            rpi_harness::session::types::LaneRecord::OperationFinished(finished)
4064                if finished.run_id == run_id)));
4065    }
4066
4067    #[tokio::test]
4068    async fn rpc_eof_drains_queued_final_events() {
4069        let harness = AgentHarness::create(Default::default()).await.unwrap();
4070        let (tx, rx) = tokio::sync::broadcast::channel(256);
4071        for _ in 0..100 {
4072            tx.send(AgentEvent::AgentStart).unwrap();
4073        }
4074        tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
4075        let output = RpcOutput::default();
4076        let code = rpc_with_io(
4077            &harness,
4078            Some(rx),
4079            vec![],
4080            rpi_extensions::UiDialogMailbox::new(),
4081            None,
4082            crate::args::RpcMessageUpdates::default(),
4083            tokio::io::BufReader::new(&b""[..]),
4084            output.clone(),
4085        )
4086        .await;
4087        assert_eq!(code, 0);
4088        let messages = output.messages();
4089        assert_eq!(
4090            messages.len(),
4091            102,
4092            "ready plus every queued event must be flushed"
4093        );
4094        assert_eq!(messages.last().unwrap()["type"], "agent_end");
4095    }
4096
4097    fn assert_rpc_update_mode(
4098        messages: &[serde_json::Value],
4099        mode: crate::args::RpcMessageUpdates,
4100    ) {
4101        let updates: Vec<_> = messages
4102            .iter()
4103            .filter(|m| {
4104                matches!(
4105                    m["eventType"].as_str(),
4106                    Some("text_delta" | "thinking_delta" | "toolcall_delta")
4107                )
4108            })
4109            .collect();
4110        assert!(
4111            !updates.is_empty(),
4112            "expected streaming deltas: {messages:?}"
4113        );
4114        for update in updates {
4115            let snapshot = mode == crate::args::RpcMessageUpdates::Snapshot;
4116            assert_eq!(update.get("message").is_some(), snapshot, "{update}");
4117            assert_eq!(
4118                update["assistantMessageEvent"].get("partial").is_some(),
4119                snapshot,
4120                "{update}"
4121            );
4122            assert_eq!(update["eventType"], update["assistantMessageEvent"]["type"]);
4123            assert_eq!(
4124                update["contentIndex"],
4125                update["assistantMessageEvent"]["contentIndex"]
4126            );
4127            assert_eq!(update["delta"], update["assistantMessageEvent"]["delta"]);
4128        }
4129    }
4130
4131    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4132    async fn rpc_selected_updates_survive_prompt_eof_and_stop() {
4133        use tokio::io::AsyncWriteExt;
4134        for mode in [
4135            crate::args::RpcMessageUpdates::CompactDeltas,
4136            crate::args::RpcMessageUpdates::Snapshot,
4137        ] {
4138            for stop in [false, true] {
4139                let (harness, _) =
4140                    rpc_test_harness("one two three four five six seven eight", 100.0).await;
4141                let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
4142                harness
4143                    .set_agent_emitter(Some(Arc::new(emitter)))
4144                    .await
4145                    .unwrap();
4146                let (mut client, input) = tokio::io::duplex(4096);
4147                let output = RpcOutput::default();
4148                let captured = output.clone();
4149                let task = tokio::spawn(async move {
4150                    rpc_with_io(
4151                        &harness,
4152                        Some(events),
4153                        vec![],
4154                        rpi_extensions::UiDialogMailbox::new(),
4155                        None,
4156                        mode,
4157                        tokio::io::BufReader::new(input),
4158                        captured,
4159                    )
4160                    .await
4161                });
4162                let ready = output.wait_for(|m| m["type"] == "ready").await;
4163                assert_eq!(ready["semantics"]["messageUpdates"], mode.as_str());
4164                client
4165                    .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4166                    .await
4167                    .unwrap();
4168                if stop {
4169                    output.wait_for(|m| m["eventType"] == "text_delta").await;
4170                    client
4171                        .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
4172                        .await
4173                        .unwrap();
4174                } else {
4175                    client.shutdown().await.unwrap();
4176                }
4177                assert_eq!(
4178                    tokio::time::timeout(std::time::Duration::from_secs(3), task)
4179                        .await
4180                        .unwrap()
4181                        .unwrap(),
4182                    0
4183                );
4184                let messages = output.messages();
4185                assert_rpc_update_mode(&messages, mode);
4186                assert!(messages.iter().any(|m| m["type"] == "message_end"));
4187                assert!(messages.iter().any(|m| m["type"] == "agent_end"));
4188                assert!(messages.iter().any(|m| m["type"] == "agent_settled"));
4189                if !stop {
4190                    assert!(messages
4191                        .iter()
4192                        .any(|m| m["type"] == "agent_settled" && m["outcome"] == "completed"));
4193                }
4194            }
4195        }
4196    }
4197
4198    #[tokio::test]
4199    async fn rpc_normal_and_explicit_drain_use_the_same_immutable_writer_mode() {
4200        for mode in [
4201            crate::args::RpcMessageUpdates::CompactDeltas,
4202            crate::args::RpcMessageUpdates::Snapshot,
4203        ] {
4204            let output = RpcOutput::default();
4205            let (writer, task, _) = start_rpc_writer(output.clone(), mode);
4206            let cloned_writer = writer.clone();
4207            let mut message = rpi_ai::AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "m", 42);
4208            message.content = vec![rpi_ai::Content::text("snapshot")];
4209            let message = Arc::new(message);
4210            let (tx, mut rx) = tokio::sync::broadcast::channel(16);
4211            for kind in 0..3 {
4212                let assistant_message_event = match kind {
4213                    0 => rpi_ai::AssistantMessageEvent::TextDelta {
4214                        content_index: 0,
4215                        delta: "text".into(),
4216                        partial: message.clone(),
4217                    },
4218                    1 => rpi_ai::AssistantMessageEvent::ThinkingDelta {
4219                        content_index: 1,
4220                        delta: "thought".into(),
4221                        partial: message.clone(),
4222                    },
4223                    _ => rpi_ai::AssistantMessageEvent::ToolCallDelta {
4224                        content_index: 2,
4225                        delta: "{}".into(),
4226                        partial: message.clone(),
4227                    },
4228                };
4229                let event = AgentEvent::MessageUpdate {
4230                    message_id: None,
4231                    message: message.clone(),
4232                    assistant_message_event,
4233                };
4234                write_agent_event(&writer, &event).await;
4235                tx.send(event).unwrap();
4236            }
4237            drain_rpc_events(&cloned_writer, &mut rx).await;
4238            drop(cloned_writer);
4239            drop(writer);
4240            task.await.unwrap().unwrap();
4241            let messages = output.messages();
4242            assert_eq!(messages.len(), 6);
4243            assert_eq!(messages[..3], messages[3..]);
4244            assert_rpc_update_mode(&messages, mode);
4245        }
4246    }
4247
4248    #[tokio::test]
4249    async fn rpc_queued_deltas_are_flushed_on_eof_and_stop_in_selected_mode() {
4250        for mode in [
4251            crate::args::RpcMessageUpdates::CompactDeltas,
4252            crate::args::RpcMessageUpdates::Snapshot,
4253        ] {
4254            for input in ["", "{\"type\":\"stop\",\"id\":1}\n"] {
4255                let harness = AgentHarness::create(Default::default()).await.unwrap();
4256                let (tx, rx) = tokio::sync::broadcast::channel(256);
4257                let mut message =
4258                    rpi_ai::AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "m", 42);
4259                message.content = vec![rpi_ai::Content::text("snapshot")];
4260                let message = Arc::new(message);
4261                for _ in 0..100 {
4262                    tx.send(AgentEvent::MessageUpdate {
4263                        message_id: None,
4264                        message: message.clone(),
4265                        assistant_message_event: rpi_ai::AssistantMessageEvent::TextDelta {
4266                            content_index: 0,
4267                            delta: "x".into(),
4268                            partial: message.clone(),
4269                        },
4270                    })
4271                    .unwrap();
4272                }
4273                tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
4274                let output = RpcOutput::default();
4275                assert_eq!(
4276                    rpc_with_io(
4277                        &harness,
4278                        Some(rx),
4279                        vec![],
4280                        rpi_extensions::UiDialogMailbox::new(),
4281                        None,
4282                        mode,
4283                        tokio::io::BufReader::new(input.as_bytes()),
4284                        output.clone()
4285                    )
4286                    .await,
4287                    0
4288                );
4289                let messages = output.messages();
4290                assert_eq!(
4291                    messages
4292                        .iter()
4293                        .filter(|m| m["eventType"] == "text_delta")
4294                        .count(),
4295                    100
4296                );
4297                assert_eq!(
4298                    messages.iter().find(|m| m["type"] == "ready").unwrap()["semantics"]
4299                        ["messageUpdates"],
4300                    mode.as_str()
4301                );
4302                assert_rpc_update_mode(&messages, mode);
4303                assert!(messages.iter().any(|m| m["type"] == "agent_end"));
4304            }
4305        }
4306    }
4307
4308    struct BackpressuredOutput {
4309        entered: Arc<std::sync::atomic::AtomicBool>,
4310        release: Arc<std::sync::atomic::AtomicBool>,
4311        output: RpcOutput,
4312    }
4313
4314    impl Write for BackpressuredOutput {
4315        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
4316            self.entered
4317                .store(true, std::sync::atomic::Ordering::SeqCst);
4318            while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
4319                std::thread::sleep(std::time::Duration::from_millis(1));
4320            }
4321            self.output.write(bytes)
4322        }
4323        fn flush(&mut self) -> std::io::Result<()> {
4324            Ok(())
4325        }
4326    }
4327
4328    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4329    async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
4330        use std::sync::atomic::{AtomicBool, Ordering};
4331        let release = Arc::new(AtomicBool::new(false));
4332        struct ReleaseOnDrop(Arc<AtomicBool>);
4333        impl Drop for ReleaseOnDrop {
4334            fn drop(&mut self) {
4335                self.0.store(true, Ordering::SeqCst);
4336            }
4337        }
4338        let _release = ReleaseOnDrop(release.clone());
4339        let entered = Arc::new(AtomicBool::new(false));
4340        let output = RpcOutput::default();
4341        let (writer, task, _) = start_rpc_writer(
4342            BackpressuredOutput {
4343                entered: entered.clone(),
4344                release: release.clone(),
4345                output: output.clone(),
4346            },
4347            crate::args::RpcMessageUpdates::default(),
4348        );
4349        write_line(&writer, &serde_json::json!({"id": 0})).await;
4350        tokio::time::timeout(std::time::Duration::from_secs(2), async {
4351            while !entered.load(Ordering::SeqCst) {
4352                tokio::task::yield_now().await;
4353            }
4354        })
4355        .await
4356        .unwrap();
4357        let producer_writer = writer.clone();
4358        let producer = tokio::spawn(async move {
4359            for id in 1..=100 {
4360                write_line(&producer_writer, &serde_json::json!({"id": id})).await;
4361            }
4362        });
4363        tokio::time::timeout(
4364            std::time::Duration::from_millis(150),
4365            tokio::time::sleep(std::time::Duration::from_millis(20)),
4366        )
4367        .await
4368        .unwrap();
4369        assert!(
4370            !producer.is_finished(),
4371            "the bounded queue must apply backpressure"
4372        );
4373        release.store(true, Ordering::SeqCst);
4374        producer.await.unwrap();
4375        drop(writer);
4376        task.await.unwrap().unwrap();
4377        let messages = output.messages();
4378        assert_eq!(messages.len(), 101);
4379        for (index, message) in messages.iter().enumerate() {
4380            assert_eq!(message["id"], index);
4381        }
4382    }
4383
4384    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4385    async fn rpc_completion_response_follows_final_event_under_backpressure() {
4386        use std::sync::atomic::{AtomicBool, Ordering};
4387        let (harness, _) = rpc_test_harness("completed", 0.0).await;
4388        let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
4389        harness
4390            .set_agent_emitter(Some(Arc::new(emitter)))
4391            .await
4392            .unwrap();
4393        let output = RpcOutput::default();
4394        let release = Arc::new(AtomicBool::new(false));
4395        let release_writer = release.clone();
4396        let release_thread = std::thread::spawn(move || {
4397            std::thread::sleep(std::time::Duration::from_millis(50));
4398            release_writer.store(true, Ordering::SeqCst);
4399        });
4400        let code = rpc_with_io(
4401            &harness,
4402            Some(events),
4403            vec![],
4404            rpi_extensions::UiDialogMailbox::new(),
4405            None,
4406            crate::args::RpcMessageUpdates::default(),
4407            tokio::io::BufReader::new(
4408                &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
4409            ),
4410            BackpressuredOutput {
4411                entered: Arc::new(AtomicBool::new(false)),
4412                release,
4413                output: output.clone(),
4414            },
4415        )
4416        .await;
4417        release_thread.join().unwrap();
4418        assert_eq!(code, 0);
4419        let messages = output.messages();
4420        let end = messages
4421            .iter()
4422            .position(|message| message["type"] == "agent_end")
4423            .unwrap();
4424        let settled = messages
4425            .iter()
4426            .position(|message| message["type"] == "agent_settled")
4427            .unwrap();
4428        assert!(end < settled);
4429        let run_id = messages[settled]["runId"].as_str().unwrap();
4430        assert!(!run_id.is_empty(), "settlement identifies its durable run");
4431        let records = harness
4432            .session()
4433            .find_records(&Default::default())
4434            .await
4435            .unwrap();
4436        assert!(records.iter().any(|record| matches!(record,
4437            rpi_harness::session::types::LaneRecord::OperationFinished(finished)
4438                if finished.run_id == run_id)));
4439        assert!(messages.iter().any(|message| {
4440            message["type"] == "response" && message["data"]["accepted"] == true
4441        }));
4442        assert!(messages.iter().any(|message| {
4443            message["type"] == "agent_settled" && message["outcome"] == "completed"
4444        }));
4445    }
4446
4447    #[tokio::test]
4448    async fn rpc_output_failure_closes_without_waiting_for_more_input() {
4449        struct BrokenOutput;
4450        impl Write for BrokenOutput {
4451            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
4452                Err(std::io::Error::new(
4453                    std::io::ErrorKind::BrokenPipe,
4454                    "closed client output",
4455                ))
4456            }
4457            fn flush(&mut self) -> std::io::Result<()> {
4458                Ok(())
4459            }
4460        }
4461        let harness = AgentHarness::create(Default::default()).await.unwrap();
4462        let (_client, input) = tokio::io::duplex(4096);
4463        let ui = rpi_extensions::UiDialogMailbox::new();
4464        let code = tokio::time::timeout(
4465            std::time::Duration::from_secs(2),
4466            rpc_with_io(
4467                &harness,
4468                None,
4469                vec![],
4470                ui.clone(),
4471                None,
4472                crate::args::RpcMessageUpdates::default(),
4473                tokio::io::BufReader::new(input),
4474                BrokenOutput,
4475            ),
4476        )
4477        .await
4478        .unwrap();
4479        assert_eq!(code, 1);
4480        assert!(!ui.is_attached());
4481    }
4482
4483    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4484    async fn rpc_stop_aborts_and_joins_active_prompt() {
4485        use tokio::io::AsyncWriteExt;
4486        let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
4487        let lane = harness.lane("main");
4488        let (mut client, output, task) =
4489            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
4490        client
4491            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4492            .await
4493            .unwrap();
4494        tokio::time::timeout(std::time::Duration::from_secs(3), async {
4495            while provider
4496                .state()
4497                .call_count
4498                .load(std::sync::atomic::Ordering::SeqCst)
4499                == 0
4500            {
4501                tokio::task::yield_now().await;
4502            }
4503        })
4504        .await
4505        .unwrap();
4506        client
4507            .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
4508            .await
4509            .unwrap();
4510        assert_eq!(
4511            tokio::time::timeout(std::time::Duration::from_secs(3), task)
4512                .await
4513                .unwrap()
4514                .unwrap(),
4515            0
4516        );
4517        let messages = output.messages();
4518        assert!(messages.iter().any(|message| {
4519            message["type"] == "agent_settled"
4520                && (message["outcome"] == "aborted" || message["outcome"] == "failed")
4521        }));
4522        assert_eq!(
4523            messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
4524            true
4525        );
4526        assert!(
4527            lane.abort().await.is_err(),
4528            "RPC returned with an active lane"
4529        );
4530    }
4531
4532    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4533    async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
4534        use tokio::io::AsyncWriteExt;
4535        let (harness, _) = rpc_test_harness("not completed", 20.0).await;
4536        let lane = harness.lane("main");
4537        let (mut client, output, task) =
4538            start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
4539        client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
4540        assert_eq!(
4541            tokio::time::timeout(std::time::Duration::from_secs(3), task)
4542                .await
4543                .unwrap()
4544                .unwrap(),
4545            0
4546        );
4547        let messages = output.messages();
4548        let response = messages
4549            .iter()
4550            .find(|message| message["type"] == "response" && message["id"] == 1)
4551            .expect("the prompt has one correlated admission outcome");
4552        assert_eq!(
4553            messages
4554                .iter()
4555                .filter(|message| { message["type"] == "response" && message["id"] == 1 })
4556                .count(),
4557            1
4558        );
4559        if response["success"] == true {
4560            assert_eq!(response["data"]["accepted"], true);
4561            assert!(messages.iter().any(|message| {
4562                message["type"] == "agent_settled"
4563                    && (message["outcome"] == "aborted" || message["outcome"] == "failed")
4564            }));
4565        } else {
4566            // Stop may cancel the owned preflight BEFORE acceptance. In this
4567            // case there is no native run to settle, and no user is persisted.
4568            assert_eq!(
4569                response["error"].as_str(),
4570                Some("Operation aborted during prompt validation"),
4571                "{response}"
4572            );
4573            assert!(!messages
4574                .iter()
4575                .any(|message| message["type"] == "agent_start"));
4576        }
4577        assert!(messages.iter().any(|message| message["type"] == "response"
4578            && message["id"] == 2
4579            && message["success"] == true));
4580        assert!(lane.abort().await.is_err());
4581    }
4582
4583    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4584    async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
4585        use tokio::io::AsyncWriteExt;
4586        let (harness, _) =
4587            rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
4588        let ui = rpi_extensions::UiDialogMailbox::new();
4589        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4590        output.wait_for(|m| m["type"] == "ready").await;
4591        client
4592            .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4593            .await
4594            .unwrap();
4595        ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
4596            .unwrap();
4597        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
4598        client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
4599        output.wait_for(|m| m["id"] == 2).await;
4600        assert_eq!(ui.poll("approval").unwrap()["answer"], true);
4601        output
4602            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
4603            .await;
4604        client.shutdown().await.unwrap();
4605        assert_eq!(
4606            tokio::time::timeout(std::time::Duration::from_secs(3), task)
4607                .await
4608                .unwrap()
4609                .unwrap(),
4610            0
4611        );
4612        assert!(output.messages().iter().any(|message| {
4613            message["type"] == "agent_settled" && message["outcome"] == "completed"
4614        }));
4615    }
4616
4617    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4618    async fn rpc_notifies_client_when_an_approval_is_cancelled() {
4619        use tokio::io::AsyncWriteExt;
4620        let harness = AgentHarness::create(Default::default()).await.unwrap();
4621        let ui = rpi_extensions::UiDialogMailbox::new();
4622        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4623        output.wait_for(|m| m["type"] == "ready").await;
4624        ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
4625            .unwrap();
4626        output.wait_for(|m| m["type"] == "ui_dialog_request").await;
4627        ui.cancel_tool_call("c");
4628        output
4629            .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
4630            .await;
4631        // The notification must not consume the cancellation meant for the plugin.
4632        assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
4633        client.shutdown().await.unwrap();
4634        assert_eq!(task.await.unwrap(), 0);
4635    }
4636
4637    #[tokio::test]
4638    async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
4639        use crate::remote::protocol::RemoteCommand;
4640        use tokio::io::AsyncWriteExt;
4641        let harness = AgentHarness::create(Default::default()).await.unwrap();
4642        let ui = rpi_extensions::UiDialogMailbox::new();
4643        let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4644        output.wait_for(|m| m["type"] == "ready").await;
4645        for (request_id, answer, response_id) in [
4646            (
4647                "answered",
4648                Some(serde_json::json!({"values":["No"],"kind":"selector"})),
4649                5,
4650            ),
4651            ("cancelled", None, 6),
4652        ] {
4653            ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
4654                .unwrap();
4655            output
4656                .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
4657                .await;
4658            let command = RemoteCommand::UiDialogResponse {
4659                request_id: request_id.into(),
4660                cancelled: answer.is_none(),
4661                answer,
4662            }
4663            .with_id(response_id);
4664            client
4665                .write_all(format!("{command}\n").as_bytes())
4666                .await
4667                .unwrap();
4668            let ack = output.wait_for(|m| m["id"] == response_id).await;
4669            assert_eq!(ack["data"]["accepted"], true);
4670            let result = ui.poll(request_id).unwrap();
4671            if request_id == "answered" {
4672                assert_eq!(result["answer"]["values"][0], "No");
4673            } else {
4674                assert_eq!(result["status"], "cancelled");
4675            }
4676        }
4677        client.shutdown().await.unwrap();
4678        assert_eq!(task.await.unwrap(), 0);
4679    }
4680
4681    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
4682        AssistantMessage {
4683            role: rpi_ai::types::AssistantRole,
4684            content: vec![Content::Text(TextContent {
4685                kind: TextContentType,
4686                text: text.into(),
4687                text_signature: None,
4688            })],
4689            api: rpi_ai::Api::AnthropicMessages,
4690            provider: "anthropic".into(),
4691            model: "claude-sonnet-5".into(),
4692            response_model: None,
4693            response_id: None,
4694            usage: Usage::zero(),
4695            stop_reason: stop,
4696            deferred: None,
4697            error_message: None,
4698            raw_stop_reason: None,
4699            end_turn: None,
4700            timestamp: 0,
4701        }
4702    }
4703
4704    #[test]
4705    fn assistant_text_concatenates_text_blocks() {
4706        let m = assistant("hello", StopReason::Stop);
4707        assert_eq!(assistant_text(&m), "hello");
4708    }
4709
4710    #[test]
4711    fn outcome_exit_code_maps_failed_aborted_to_1() {
4712        let failed = HarnessRunOutcome::Failed {
4713            leaf_id: "l".into(),
4714            error: OperationError {
4715                code: "boom".into(),
4716                message: "boom".into(),
4717            },
4718            final_entry_id: None,
4719            final_message: None,
4720        };
4721        assert_eq!(outcome_exit_code(&failed), 1);
4722        let completed = HarnessRunOutcome::Completed {
4723            leaf_id: "l".into(),
4724            final_entry_id: "e".into(),
4725            final_message: assistant("ok", StopReason::Stop),
4726        };
4727        assert_eq!(outcome_exit_code(&completed), 0);
4728    }
4729
4730    #[test]
4731    fn run_end_outcome_str_roundtrip() {
4732        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
4733        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
4734        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
4735    }
4736
4737    #[test]
4738    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
4739        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
4740        assert_eq!(end["type"], "agent_end");
4741        assert_eq!(end["messages"], serde_json::json!([]));
4742
4743        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
4744            tool_call_id: "call-1".into(),
4745            tool_name: "read".into(),
4746            result: AgentToolResult::text("hello"),
4747            is_error: false,
4748        });
4749        assert_eq!(tool["type"], "tool_execution_end");
4750        assert_eq!(tool["result"]["content"][0]["text"], "hello");
4751        assert_eq!(tool["result"]["terminate"], false);
4752
4753        let retry = agent_event_json(&AgentEvent::RetryScheduled {
4754            attempt: 3,
4755            max_retries: 10,
4756            delay_ms: 8_000,
4757            error: "503 service unavailable".into(),
4758        });
4759        assert_eq!(retry["type"], "retry_scheduled");
4760        assert_eq!(retry["attempt"], 3);
4761        assert_eq!(retry["maxRetries"], 10);
4762        assert_eq!(retry["delayMs"], 8_000);
4763        assert_eq!(retry["error"], "503 service unavailable");
4764    }
4765}
4766
4767#[cfg(test)]
4768mod page_window_tests {
4769    use super::page_window;
4770
4771    #[test]
4772    fn tail_is_kept_without_before_id() {
4773        // 12 items, limit 5 -> keep the last 5, more behind.
4774        assert_eq!(page_window(12, 5, None), (7..12, true));
4775        // Fewer items than the limit -> everything, nothing behind.
4776        assert_eq!(page_window(3, 5, None), (0..3, false));
4777    }
4778
4779    #[test]
4780    fn before_id_opens_the_window_immediately_before_it() {
4781        // Page back from index 10 with limit 4 -> [6,10), more behind.
4782        assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
4783        // Same, but reaching the very start -> no more behind.
4784        assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
4785    }
4786
4787    #[test]
4788    fn limit_larger_than_the_branch_never_underflows() {
4789        assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
4790        assert_eq!(page_window(5, 100, None), (0..5, false));
4791    }
4792}
4793
4794#[cfg(test)]
4795mod session_storage_notice_tests {
4796    use super::session_storage_notice;
4797
4798    /// Print mode cannot recover a refused write by exporting: it is about to
4799    /// exit and offers no interactive `/export`. The message must say what was
4800    /// lost instead of naming a recovery the user cannot run from here.
4801    #[test]
4802    fn the_headless_notice_does_not_promise_an_unavailable_recourse() {
4803        let notice = session_storage_notice("disk full");
4804        assert!(notice.contains("disk full"), "{notice}");
4805        assert!(
4806            !notice.contains("/export"),
4807            "print mode has no interactive /export: {notice}"
4808        );
4809        assert!(
4810            notice.contains("print mode exits"),
4811            "the notice must say the turn is not coming back: {notice}"
4812        );
4813    }
4814}