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