1use serde_json::Value;
9
10use crate::remote::protocol::{RemoteEvent, RemoteResponse, SessionState};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct ToolItem {
15 pub id: String,
16 pub name: String,
17 pub args: Value,
18 pub partial: Option<String>,
20 pub result: Option<String>,
22 pub is_error: bool,
23 pub done: bool,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub enum TranscriptItem {
29 User(String),
31 Assistant(String),
33 Thinking(String),
35 Tool(ToolItem),
37 Notice(String),
39}
40
41#[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 #[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 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 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
130pub 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
151pub 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
160pub 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#[derive(Debug, Clone, Default)]
178pub struct RemoteSession {
179 pub transcript: Transcript,
180 pub state: SessionState,
181 pub streaming: bool,
183 pub ready: bool,
185 pub last_error: Option<String>,
187 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 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 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 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 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 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); 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}