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('[');
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 if message_role(message) == "assistant" {
273 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); 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}