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