1use serde_json::Value;
9
10use crate::remote::protocol::{RemoteEvent, RemoteResponse, SessionState};
11
12pub use crate::transcript_view::tool_result_text;
15
16#[derive(Debug, Clone, PartialEq)]
18pub struct ToolItem {
19 pub id: String,
20 pub name: String,
21 pub args: Value,
22 pub partial: Option<String>,
24 pub result: Option<String>,
26 pub is_error: bool,
27 pub done: bool,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum TranscriptItem {
33 User(String),
35 Assistant(String),
37 Thinking(String),
39 Tool(ToolItem),
41 Notice(String),
43}
44
45#[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 #[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 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 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
134pub 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
155pub 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#[derive(Debug, Clone, Default)]
168pub struct RemoteSession {
169 pub transcript: Transcript,
170 pub state: SessionState,
171 pub streaming: bool,
173 pub ready: bool,
175 pub last_error: Option<String>,
177 pub retry_attempt: Option<(u32, u32)>,
179 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 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 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 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 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 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); 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}