Skip to main content

rpi_cli/remote/
session.rs

1//! Client-side session model: folds the server's event stream into a
2//! render-friendly transcript plus a small amount of session state.
3//!
4//! This is deliberately independent of any local agent harness — it is the
5//! client analogue of the TS `packages/coding-agent/src/client/` remote-session
6//! layer, built on rpi's own protocol types.
7
8use serde_json::Value;
9
10use crate::remote::protocol::{RemoteEvent, RemoteResponse, SessionState};
11
12/// Re-exported from the shared render layer so the wire `Value` parsing lives in
13/// exactly one place (the session fold and the transcript view both use it).
14pub use crate::transcript_view::tool_result_text;
15
16/// A tool invocation as rendered in the transcript.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ToolItem {
19    pub id: String,
20    pub name: String,
21    pub args: Value,
22    /// Latest partial output reported via `on_update`, if any.
23    pub partial: Option<String>,
24    /// Final output once the tool finished.
25    pub result: Option<String>,
26    pub is_error: bool,
27    pub done: bool,
28}
29
30/// One item in the remote transcript.
31#[derive(Debug, Clone, PartialEq)]
32pub enum TranscriptItem {
33    /// A prompt the local user submitted.
34    User(String),
35    /// Assistant response text (grows as deltas arrive).
36    Assistant(String),
37    /// Assistant reasoning text (grows as deltas arrive).
38    Thinking(String),
39    /// A tool call with its (partial/final) output.
40    Tool(ToolItem),
41    /// A local notice (retry, error, connection status).
42    Notice(String),
43}
44
45/// The transcript the UI renders, in order.
46#[derive(Debug, Clone, Default)]
47pub struct Transcript {
48    pub items: Vec<TranscriptItem>,
49}
50
51impl Transcript {
52    pub fn is_empty(&self) -> bool {
53        self.items.is_empty()
54    }
55
56    pub fn push_user(&mut self, text: impl Into<String>) {
57        self.items.push(TranscriptItem::User(text.into()));
58    }
59
60    pub fn push_notice(&mut self, text: impl Into<String>) {
61        self.items.push(TranscriptItem::Notice(text.into()));
62    }
63
64    fn push_assistant(&mut self) -> usize {
65        self.items.push(TranscriptItem::Assistant(String::new()));
66        self.items.len() - 1
67    }
68
69    fn push_thinking(&mut self) -> usize {
70        self.items.push(TranscriptItem::Thinking(String::new()));
71        self.items.len() - 1
72    }
73
74    /// Index of the last item of a given variant kind.
75    #[allow(dead_code)]
76    fn last_index<F: Fn(&TranscriptItem) -> bool>(&self, pred: F) -> Option<usize> {
77        self.items.iter().rposition(pred)
78    }
79
80    /// Find a tool item by call id.
81    pub fn tool_mut(&mut self, id: &str) -> Option<&mut ToolItem> {
82        self.items.iter_mut().find_map(|item| match item {
83            TranscriptItem::Tool(tool) if tool.id == id => Some(tool),
84            _ => None,
85        })
86    }
87
88    /// Plain-text projection (used for logs, tests, and non-TUI output).
89    pub fn to_text(&self) -> String {
90        let mut out = String::new();
91        for item in &self.items {
92            match item {
93                TranscriptItem::User(text) => {
94                    out.push_str("> ");
95                    out.push_str(text);
96                    out.push('\n');
97                }
98                TranscriptItem::Assistant(text) => {
99                    out.push_str(text);
100                    out.push('\n');
101                }
102                TranscriptItem::Thinking(text) => {
103                    out.push_str("[thinking] ");
104                    out.push_str(text);
105                    out.push('\n');
106                }
107                TranscriptItem::Tool(tool) => {
108                    let status = if tool.done {
109                        if tool.is_error {
110                            "error"
111                        } else {
112                            "ok"
113                        }
114                    } else {
115                        "running"
116                    };
117                    out.push_str(&format!("[tool {} {}]\n", tool.name, status));
118                    if let Some(body) = tool.result.as_ref().or(tool.partial.as_ref()) {
119                        out.push_str(body);
120                        out.push('\n');
121                    }
122                }
123                TranscriptItem::Notice(text) => {
124                    out.push_str("[");
125                    out.push_str(text);
126                    out.push_str("]\n");
127                }
128            }
129        }
130        out
131    }
132}
133
134/// Extract concatenated text from a serialized `AgentMessage`/`AssistantMessage`
135/// (`content: [{type:"text",text:…}]`).
136pub fn message_text(message: &Value) -> String {
137    let mut out = String::new();
138    if let Some(content) = message.get("content") {
139        if let Some(text) = content.as_str() {
140            return text.to_string();
141        }
142        if let Some(blocks) = content.as_array() {
143            for block in blocks {
144                if block.get("type").and_then(Value::as_str) == Some("text") {
145                    if let Some(text) = block.get("text").and_then(Value::as_str) {
146                        out.push_str(text);
147                    }
148                }
149            }
150        }
151    }
152    out
153}
154
155/// The logical role of a serialized message (`kind` or `role`).
156pub fn message_role(message: &Value) -> &str {
157    message
158        .get("kind")
159        .or_else(|| message.get("role"))
160        .and_then(Value::as_str)
161        .unwrap_or("")
162}
163
164/// Text out of a wire tool-result payload (`{content:[{type:"text",text}]}`).
165
166/// Client-side session state: the transcript plus the latest server state.
167#[derive(Debug, Clone, Default)]
168pub struct RemoteSession {
169    pub transcript: Transcript,
170    pub state: SessionState,
171    /// True between `agent_start` and `agent_end`.
172    pub streaming: bool,
173    /// Set once the server sent its `ready` line.
174    pub ready: bool,
175    /// Most recent error surfaced locally or by the server.
176    pub last_error: Option<String>,
177    /// Number of retries currently being waited on (0 when not retrying).
178    pub retry_attempt: Option<(u32, u32)>,
179    /// Delay of the pending retry in milliseconds (drives the status countdown).
180    pub retry_delay_ms: Option<u64>,
181    open_assistant: Option<usize>,
182    open_thinking: Option<usize>,
183}
184
185impl RemoteSession {
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Record a locally-submitted prompt so it renders immediately.
191    pub fn on_user_prompt(&mut self, text: impl Into<String>) {
192        self.transcript.push_user(text);
193        self.streaming = true;
194        self.open_assistant = None;
195        self.open_thinking = None;
196    }
197
198    pub fn push_notice(&mut self, text: impl Into<String>) {
199        self.transcript.push_notice(text);
200    }
201
202    /// Apply a raw child line (`ready` / event / response) as forwarded by the
203    /// server's `event` notifications.
204    pub fn apply_line(&mut self, line: &Value) {
205        match line.get("type").and_then(Value::as_str) {
206            Some("ready") => self.ready = true,
207            Some("response") => {
208                if let Ok(response) = serde_json::from_value::<RemoteResponse>(line.clone()) {
209                    self.apply_response(&response);
210                }
211            }
212            Some(_) => {
213                if let Ok(event) = serde_json::from_value::<RemoteEvent>(line.clone()) {
214                    self.apply_event(&event);
215                }
216            }
217            None => {}
218        }
219    }
220
221    /// Apply a decoded response (e.g. to a `get_state` command).
222    pub fn apply_response(&mut self, response: &RemoteResponse) {
223        if !response.is_ok() {
224            if let Some(error) = &response.error {
225                self.last_error = Some(error.clone());
226                self.transcript.push_notice(format!("error: {error}"));
227            }
228            return;
229        }
230        let Some(result) = &response.result else {
231            return;
232        };
233        if result.get("sessionId").is_some() && result.get("activeTools").is_some() {
234            if let Ok(state) = serde_json::from_value::<SessionState>(result.clone()) {
235                self.state = state;
236            }
237        }
238    }
239
240    /// Fold one lifecycle event into the transcript.
241    pub fn apply_event(&mut self, event: &RemoteEvent) {
242        match event {
243            RemoteEvent::UiDialogRequest { .. } | RemoteEvent::UiDialogClosed { .. } => {}
244            RemoteEvent::AgentStart => {
245                self.streaming = true;
246                self.retry_attempt = None;
247                self.retry_delay_ms = None;
248                self.open_assistant = None;
249                self.open_thinking = None;
250            }
251            RemoteEvent::AgentEnd { .. } => {
252                self.streaming = false;
253                self.retry_attempt = None;
254                self.retry_delay_ms = None;
255                self.open_assistant = None;
256                self.open_thinking = None;
257            }
258            RemoteEvent::RetryScheduled {
259                attempt,
260                max_retries,
261                delay_ms,
262                error,
263            } => {
264                self.retry_attempt = Some((*attempt, *max_retries));
265                self.retry_delay_ms = Some(*delay_ms);
266                self.transcript.push_notice(format!(
267                    "retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
268                ));
269            }
270            RemoteEvent::TurnStart | RemoteEvent::TurnEnd { .. } => {}
271            RemoteEvent::MessageStart { message } => {
272                match message_role(message) {
273                    "assistant" => {
274                        // If the previous assistant item is still open with no
275                        // content, reuse it; otherwise start a new block.
276                        self.open_assistant = Some(self.transcript.push_assistant());
277                        self.open_thinking = None;
278                    }
279                    _ => {}
280                }
281            }
282            RemoteEvent::MessageEnd { message } => {
283                if message_role(message) == "assistant" {
284                    let text = message_text(message);
285                    if let Some(index) = self.open_assistant.take() {
286                        if let Some(TranscriptItem::Assistant(existing)) =
287                            self.transcript.items.get_mut(index)
288                        {
289                            if !text.is_empty() {
290                                *existing = text;
291                            }
292                        }
293                    } else if !text.is_empty() {
294                        self.transcript.items.push(TranscriptItem::Assistant(text));
295                    }
296                }
297                self.open_thinking = None;
298            }
299            RemoteEvent::MessageUpdate {
300                event_type,
301                delta,
302                content_index,
303                ..
304            } => {
305                let Some(delta) = delta else { return };
306                if delta.is_empty() {
307                    return;
308                }
309                match event_type.as_str() {
310                    "text_delta" => {
311                        let index = match self.open_assistant {
312                            Some(index) => index,
313                            None => {
314                                let index = self.transcript.push_assistant();
315                                self.open_assistant = Some(index);
316                                index
317                            }
318                        };
319                        if let Some(TranscriptItem::Assistant(text)) =
320                            self.transcript.items.get_mut(index)
321                        {
322                            text.push_str(delta);
323                        }
324                    }
325                    "thinking_delta" => {
326                        let index = match self.open_thinking {
327                            Some(index) => index,
328                            None => {
329                                let index = self.transcript.push_thinking();
330                                self.open_thinking = Some(index);
331                                index
332                            }
333                        };
334                        if let Some(TranscriptItem::Thinking(text)) =
335                            self.transcript.items.get_mut(index)
336                        {
337                            text.push_str(delta);
338                        }
339                    }
340                    "tool_call_delta" => {
341                        let _ = content_index;
342                    }
343                    _ => {}
344                }
345            }
346            RemoteEvent::ToolExecutionStart {
347                tool_call_id,
348                tool_name,
349                args,
350            } => {
351                self.transcript.items.push(TranscriptItem::Tool(ToolItem {
352                    id: tool_call_id.clone(),
353                    name: tool_name.clone(),
354                    args: args.clone(),
355                    partial: None,
356                    result: None,
357                    is_error: false,
358                    done: false,
359                }));
360            }
361            RemoteEvent::ToolExecutionUpdate {
362                tool_call_id,
363                partial_result,
364                ..
365            } => {
366                let text = tool_result_text(partial_result);
367                if let Some(tool) = self.transcript.tool_mut(tool_call_id) {
368                    tool.partial = Some(text);
369                }
370            }
371            RemoteEvent::ToolExecutionEnd {
372                tool_call_id,
373                result,
374                is_error,
375                ..
376            } => {
377                let text = tool_result_text(result);
378                if let Some(tool) = self.transcript.tool_mut(tool_call_id) {
379                    tool.result = Some(text);
380                    tool.is_error = *is_error;
381                    tool.done = true;
382                }
383            }
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use serde_json::json;
392
393    fn event(value: serde_json::Value) -> RemoteEvent {
394        serde_json::from_value(value).unwrap()
395    }
396
397    #[test]
398    fn folds_text_stream_into_one_assistant_block() {
399        let mut session = RemoteSession::new();
400        session.on_user_prompt("hi");
401        session.apply_event(&event(json!({"type": "agent_start"})));
402        session.apply_event(&event(json!({
403            "type": "message_start",
404            "message": {"kind": "assistant", "content": []}
405        })));
406        for delta in ["Hel", "lo", "!"] {
407            session.apply_event(&event(json!({
408                "type": "message_update",
409                "message": {"kind": "assistant", "content": []},
410                "assistantMessageEvent": {"type": "text_delta"},
411                "eventType": "text_delta",
412                "contentIndex": 0,
413                "delta": delta,
414            })));
415        }
416        session.apply_event(&event(json!({
417            "type": "message_end",
418            "message": {"kind": "assistant", "content": [{"type": "text", "text": "Hello!"}]}
419        })));
420        session.apply_event(&event(
421            json!({"type": "agent_end", "messageCount": 1, "messages": []}),
422        ));
423
424        assert!(!session.streaming);
425        assert_eq!(session.transcript.items.len(), 2); // user + assistant
426        assert_eq!(
427            session.transcript.items[1],
428            TranscriptItem::Assistant("Hello!".into())
429        );
430    }
431
432    #[test]
433    fn tracks_tool_lifecycle_and_errors() {
434        let mut session = RemoteSession::new();
435        session.apply_event(&event(json!({
436            "type": "tool_execution_start",
437            "toolCallId": "c1",
438            "toolName": "read",
439            "args": {"path": "a.txt"}
440        })));
441        session.apply_event(&event(json!({
442            "type": "tool_execution_end",
443            "toolCallId": "c1",
444            "toolName": "read",
445            "isError": true,
446            "result": {"content": [{"type": "text", "text": "missing"}]}
447        })));
448
449        match &session.transcript.items[0] {
450            TranscriptItem::Tool(tool) => {
451                assert_eq!(tool.name, "read");
452                assert!(tool.done);
453                assert!(tool.is_error);
454                assert_eq!(tool.result.as_deref(), Some("missing"));
455            }
456            other => panic!("expected tool, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn thinking_deltas_get_their_own_block() {
462        let mut session = RemoteSession::new();
463        session.apply_event(&event(json!({
464            "type": "message_update",
465            "assistantMessageEvent": {"type": "thinking_delta"},
466            "eventType": "thinking_delta",
467            "delta": "why",
468        })));
469        assert_eq!(
470            session.transcript.items[0],
471            TranscriptItem::Thinking("why".into())
472        );
473    }
474
475    #[test]
476    fn ready_and_error_lines_update_state() {
477        let mut session = RemoteSession::new();
478        session.apply_line(&json!({"type": "ready"}));
479        assert!(session.ready);
480        session.apply_line(&json!({
481            "type": "response", "id": "1", "status": "error", "error": "boom"
482        }));
483        assert_eq!(session.last_error.as_deref(), Some("boom"));
484        session.apply_line(&json!({
485            "type": "response", "id": "2", "status": "ok",
486            "result": {"model": "m", "thinkingLevel": "off",
487                       "activeTools": ["read"], "sessionId": "s1"}
488        }));
489        assert_eq!(session.state.model.as_deref(), Some("m"));
490        assert_eq!(session.state.active_tools, vec!["read".to_string()]);
491    }
492}