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('[');
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                if message_role(message) == "assistant" {
273                    // If the previous assistant item is still open with no
274                    // content, reuse it; otherwise start a new block.
275                    self.open_assistant = Some(self.transcript.push_assistant());
276                    self.open_thinking = None;
277                }
278            }
279            RemoteEvent::MessageEnd { message } => {
280                if message_role(message) == "assistant" {
281                    let text = message_text(message);
282                    if let Some(index) = self.open_assistant.take() {
283                        if let Some(TranscriptItem::Assistant(existing)) =
284                            self.transcript.items.get_mut(index)
285                        {
286                            if !text.is_empty() {
287                                *existing = text;
288                            }
289                        }
290                    } else if !text.is_empty() {
291                        self.transcript.items.push(TranscriptItem::Assistant(text));
292                    }
293                }
294                self.open_thinking = None;
295            }
296            RemoteEvent::MessageUpdate {
297                event_type,
298                delta,
299                content_index,
300                ..
301            } => {
302                let Some(delta) = delta else { return };
303                if delta.is_empty() {
304                    return;
305                }
306                match event_type.as_str() {
307                    "text_delta" => {
308                        let index = match self.open_assistant {
309                            Some(index) => index,
310                            None => {
311                                let index = self.transcript.push_assistant();
312                                self.open_assistant = Some(index);
313                                index
314                            }
315                        };
316                        if let Some(TranscriptItem::Assistant(text)) =
317                            self.transcript.items.get_mut(index)
318                        {
319                            text.push_str(delta);
320                        }
321                    }
322                    "thinking_delta" => {
323                        let index = match self.open_thinking {
324                            Some(index) => index,
325                            None => {
326                                let index = self.transcript.push_thinking();
327                                self.open_thinking = Some(index);
328                                index
329                            }
330                        };
331                        if let Some(TranscriptItem::Thinking(text)) =
332                            self.transcript.items.get_mut(index)
333                        {
334                            text.push_str(delta);
335                        }
336                    }
337                    "tool_call_delta" => {
338                        let _ = content_index;
339                    }
340                    _ => {}
341                }
342            }
343            RemoteEvent::ToolExecutionStart {
344                tool_call_id,
345                tool_name,
346                args,
347            } => {
348                self.transcript.items.push(TranscriptItem::Tool(ToolItem {
349                    id: tool_call_id.clone(),
350                    name: tool_name.clone(),
351                    args: args.clone(),
352                    partial: None,
353                    result: None,
354                    is_error: false,
355                    done: false,
356                }));
357            }
358            RemoteEvent::ToolExecutionUpdate {
359                tool_call_id,
360                partial_result,
361                ..
362            } => {
363                let text = tool_result_text(partial_result);
364                if let Some(tool) = self.transcript.tool_mut(tool_call_id) {
365                    tool.partial = Some(text);
366                }
367            }
368            RemoteEvent::ToolExecutionEnd {
369                tool_call_id,
370                result,
371                is_error,
372                ..
373            } => {
374                let text = tool_result_text(result);
375                if let Some(tool) = self.transcript.tool_mut(tool_call_id) {
376                    tool.result = Some(text);
377                    tool.is_error = *is_error;
378                    tool.done = true;
379                }
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    fn event(value: serde_json::Value) -> RemoteEvent {
391        serde_json::from_value(value).unwrap()
392    }
393
394    #[test]
395    fn folds_text_stream_into_one_assistant_block() {
396        let mut session = RemoteSession::new();
397        session.on_user_prompt("hi");
398        session.apply_event(&event(json!({"type": "agent_start"})));
399        session.apply_event(&event(json!({
400            "type": "message_start",
401            "message": {"kind": "assistant", "content": []}
402        })));
403        for delta in ["Hel", "lo", "!"] {
404            session.apply_event(&event(json!({
405                "type": "message_update",
406                "message": {"kind": "assistant", "content": []},
407                "assistantMessageEvent": {"type": "text_delta"},
408                "eventType": "text_delta",
409                "contentIndex": 0,
410                "delta": delta,
411            })));
412        }
413        session.apply_event(&event(json!({
414            "type": "message_end",
415            "message": {"kind": "assistant", "content": [{"type": "text", "text": "Hello!"}]}
416        })));
417        session.apply_event(&event(
418            json!({"type": "agent_end", "messageCount": 1, "messages": []}),
419        ));
420
421        assert!(!session.streaming);
422        assert_eq!(session.transcript.items.len(), 2); // user + assistant
423        assert_eq!(
424            session.transcript.items[1],
425            TranscriptItem::Assistant("Hello!".into())
426        );
427    }
428
429    #[test]
430    fn tracks_tool_lifecycle_and_errors() {
431        let mut session = RemoteSession::new();
432        session.apply_event(&event(json!({
433            "type": "tool_execution_start",
434            "toolCallId": "c1",
435            "toolName": "read",
436            "args": {"path": "a.txt"}
437        })));
438        session.apply_event(&event(json!({
439            "type": "tool_execution_end",
440            "toolCallId": "c1",
441            "toolName": "read",
442            "isError": true,
443            "result": {"content": [{"type": "text", "text": "missing"}]}
444        })));
445
446        match &session.transcript.items[0] {
447            TranscriptItem::Tool(tool) => {
448                assert_eq!(tool.name, "read");
449                assert!(tool.done);
450                assert!(tool.is_error);
451                assert_eq!(tool.result.as_deref(), Some("missing"));
452            }
453            other => panic!("expected tool, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn thinking_deltas_get_their_own_block() {
459        let mut session = RemoteSession::new();
460        session.apply_event(&event(json!({
461            "type": "message_update",
462            "assistantMessageEvent": {"type": "thinking_delta"},
463            "eventType": "thinking_delta",
464            "delta": "why",
465        })));
466        assert_eq!(
467            session.transcript.items[0],
468            TranscriptItem::Thinking("why".into())
469        );
470    }
471
472    #[test]
473    fn ready_and_error_lines_update_state() {
474        let mut session = RemoteSession::new();
475        session.apply_line(&json!({"type": "ready"}));
476        assert!(session.ready);
477        session.apply_line(&json!({
478            "type": "response", "id": "1", "status": "error", "error": "boom"
479        }));
480        assert_eq!(session.last_error.as_deref(), Some("boom"));
481        session.apply_line(&json!({
482            "type": "response", "id": "2", "status": "ok",
483            "result": {"model": "m", "thinkingLevel": "off",
484                       "activeTools": ["read"], "sessionId": "s1"}
485        }));
486        assert_eq!(session.state.model.as_deref(), Some("m"));
487        assert_eq!(session.state.active_tools, vec!["read".to_string()]);
488    }
489}