1use super::{Delivery, DeliveryKind, DeliveryState, Event, Kind};
4use rig_core::message::{
5 AssistantContent, Message, ToolCall, ToolResult, ToolResultContent, UserContent,
6};
7use std::collections::{BTreeMap, BTreeSet};
8
9const HEADER_CHARS: usize = 180;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub struct BlockId(pub usize);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum State {
17 Running,
18 Succeeded,
19 Failed(String),
20 CompletedUnknown,
21 Unfinished,
22}
23impl State {
24 fn label(&self) -> &str {
25 match self {
26 Self::Running => "running",
27 Self::Succeeded => "done",
28 Self::Failed(_) => "failed",
29 Self::CompletedUnknown => "completed (status not recorded)",
30 Self::Unfinished => "not completed",
31 }
32 }
33}
34#[derive(Debug, Clone)]
35pub struct Activity {
36 pub name: String,
37 pub target: String,
38 pub state: State,
39 pub orphan: bool,
40}
41#[derive(Debug, Default)]
42pub struct Group {
43 pub activities: Vec<Activity>,
44}
45impl Group {
46 pub fn text(&self, expanded: bool) -> String {
47 let active = self
48 .activities
49 .iter()
50 .find(|activity| activity.state == State::Running);
51 let failed = self
52 .activities
53 .iter()
54 .filter(|activity| matches!(activity.state, State::Failed(_)))
55 .collect::<Vec<_>>();
56 let mut text = match active {
57 Some(activity) => format!(
58 "{} {} {} — running{}",
59 if expanded { "▾" } else { "▸" },
60 activity_label(&activity.name),
61 activity.target,
62 if self.activities.len() > 1 {
63 format!(" · {} activities", self.activities.len())
64 } else {
65 String::new()
66 }
67 ),
68 None => {
69 let label = self
70 .activities
71 .first()
72 .map(|activity| activity_label(&activity.name))
73 .unwrap_or("Tools");
74 let target = self
75 .activities
76 .first()
77 .map(|activity| activity.target.as_str())
78 .unwrap_or_default();
79 format!(
80 "{} {label} {target} — {}{}",
81 if expanded { "▾" } else { "▸" },
82 if failed.is_empty() {
83 "completed"
84 } else {
85 "failed"
86 },
87 if self.activities.len() > 1 {
88 format!(" · {} activities", self.activities.len())
89 } else {
90 String::new()
91 }
92 )
93 }
94 };
95 for activity in failed {
97 let State::Failed(reason) = &activity.state else {
98 unreachable!("failed activities filtered");
99 };
100 text.push_str(&format!(
101 "{}{} {} — failed: {}",
102 if expanded { "\n " } else { " · " },
103 activity.name,
104 activity.target,
105 reason
106 ));
107 }
108 if expanded {
109 for activity in &self.activities {
110 if matches!(activity.state, State::Failed(_)) {
111 continue;
112 }
113 text.push_str(&format!(
114 "\n {} {} — {}{}",
115 activity.name,
116 activity.target,
117 activity.state.label(),
118 if activity.orphan {
119 " (unmatched recorded result)"
120 } else {
121 ""
122 }
123 ));
124 }
125 } else if self.activities.iter().any(|activity| {
126 activity.orphan || matches!(activity.state, State::CompletedUnknown | State::Unfinished)
127 }) {
128 text.push_str(" · incomplete/status unreported");
129 }
130 text.push_str("\n\n");
131 text
132 }
133}
134
135pub struct Patch {
138 pub id: BlockId,
139 pub text: String,
140}
141
142struct LiveBlock {
143 id: BlockId,
144 text: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
150struct Correlation(String);
151
152impl Correlation {
153 fn of(id: &rig_core::message::CallId) -> Self {
154 Self(id.wire().into_owned())
155 }
156}
157
158#[derive(Default)]
159pub struct Transcript {
160 blocks: usize,
161 groups: BTreeMap<BlockId, Group>,
162 calls: BTreeMap<(Option<usize>, Correlation), (BlockId, usize)>,
163 active: Option<BlockId>,
164 expanded: BTreeSet<BlockId>,
165 assistant: Option<crate::types::Sha256Bytes>,
166 question: Option<(Option<usize>, crate::types::Sha256Bytes)>,
167 result_candidate: Option<(BlockId, serde_json::Value)>,
169 deliveries: BTreeMap<
170 (
171 Option<usize>,
172 usize,
173 usize,
174 DeliveryKind,
175 crate::types::DeliveryBlock,
176 ),
177 LiveBlock,
178 >,
179 delivered_turns: BTreeSet<(Option<usize>, usize)>,
180 thinking: BTreeSet<BlockId>,
181}
182fn candidate(text: &str) -> Option<serde_json::Value> {
185 if text.len() > crate::agent::verdict::MAX_RESPONSE_BYTES {
186 return None;
187 }
188 let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
189 if value.get("verdict").is_none() && value.as_object()?.len() == 1 {
190 value = value.get("result")?.clone();
191 if let Some(text) = value.as_str() {
192 value = serde_json::from_str(text).ok()?;
193 }
194 if value.as_object()?.len() == 2 && value.get("verdictFields").is_some() {
195 let verdict = value.get("verdict")?.clone();
196 let mut fields = value.get("verdictFields")?.clone();
197 if let Some(text) = fields.as_str() {
198 fields = serde_json::from_str(text).ok()?;
199 }
200 let object = fields.as_object_mut()?;
201 if object.get("verdict").is_some_and(|value| value != &verdict) {
202 return None;
203 }
204 object.insert("verdict".into(), verdict);
205 value = fields;
206 }
207 }
208 matches!(value.get("verdict")?.as_str()?, "GREEN" | "RED").then_some(value)
209}
210
211fn same_result(wire: &serde_json::Value, result: &serde_json::Value) -> bool {
212 if wire == result {
213 return true;
214 }
215 match (wire, result) {
216 (serde_json::Value::String(text), _) => {
217 serde_json::from_str(text).is_ok_and(|decoded| same_result(&decoded, result))
218 }
219 (serde_json::Value::Object(wire), serde_json::Value::Object(result)) => {
220 result
221 .iter()
222 .all(|(key, value)| wire.get(key).is_some_and(|wire| same_result(wire, value)))
223 && wire
224 .iter()
225 .all(|(key, value)| result.contains_key(key) || value.is_null())
226 }
227 (serde_json::Value::Array(wire), serde_json::Value::Array(result)) => {
228 wire.len() == result.len()
229 && wire
230 .iter()
231 .zip(result)
232 .all(|(wire, result)| same_result(wire, result))
233 }
234 _ => false,
235 }
236}
237
238impl Transcript {
239 fn slot(&mut self) -> BlockId {
240 let id = BlockId(self.blocks);
241 self.blocks += 1;
242 id
243 }
244 fn prose(&mut self, text: &str, user: bool, patches: &mut Vec<Patch>) {
245 if text.trim().is_empty() {
246 return;
247 }
248 self.active = None;
249 let candidate = if !user { candidate(text) } else { None };
250 let text = markdown(text);
251 if !user {
252 self.assistant = Some(crate::types::Sha256Bytes::of(text.trim().as_bytes()));
253 }
254 let id = self.slot();
255 self.result_candidate = candidate.map(|value| (id, value));
256 patches.push(Patch {
257 id,
258 text: format!("{}{text}\n\n", if user { "You\n" } else { "" }),
259 });
260 }
261 fn group(&mut self) -> BlockId {
262 if let Some(id) = self.active {
263 return id;
264 }
265 let id = self.slot();
266 self.groups.insert(id, Group::default());
267 self.active = Some(id);
268 id
269 }
270 fn call(&mut self, send: Option<usize>, call: &ToolCall, dirty: &mut BTreeSet<BlockId>) {
271 let key = (send, Correlation::of(&call.id));
272 if self.calls.contains_key(&key) {
274 return;
275 }
276 let id = self.group();
277 let group = self.groups.get_mut(&id).expect("active group exists");
278 let index = group.activities.len();
279 group.activities.push(Activity {
280 name: header(call.function.name.as_str()),
281 target: target(call),
282 state: State::Running,
283 orphan: false,
284 });
285 self.calls.insert(key, (id, index));
286 dirty.insert(id);
287 }
288 fn result(
289 &mut self,
290 send: Option<usize>,
291 result: &ToolResult,
292 failed: Option<bool>,
293 dirty: &mut BTreeSet<BlockId>,
294 ) {
295 let key = (send, Correlation::of(&result.call));
296 let state = match failed {
297 Some(true) => State::Failed(failure_reason(result)),
298 Some(false) => State::Succeeded,
299 None => State::CompletedUnknown,
300 };
301 let (id, index) = match self.calls.get(&key).copied() {
302 Some(link) => link,
303 None => {
304 let id = self.group();
305 let group = self.groups.get_mut(&id).expect("active group exists");
306 let index = group.activities.len();
307 group.activities.push(Activity {
308 name: header(result.name.as_str()),
309 target: "target not recorded".into(),
310 state: state.clone(),
311 orphan: true,
312 });
313 self.calls.insert(key, (id, index));
314 (id, index)
315 }
316 };
317 self.groups
318 .get_mut(&id)
319 .expect("linked group exists")
320 .activities[index]
321 .state = state;
322 dirty.insert(id);
323 }
324 fn stopped(&mut self, dirty: &mut BTreeSet<BlockId>) {
325 for (id, group) in &mut self.groups {
326 for activity in &mut group.activities {
327 if activity.state == State::Running {
328 activity.state = State::Unfinished;
329 dirty.insert(*id);
330 }
331 }
332 }
333 self.active = None;
334 }
335 fn delivery(&mut self, send: Option<usize>, delivery: &Delivery) -> Vec<Patch> {
336 let mut patches = Vec::new();
337 if delivery.block.is_attempt() && delivery.state == DeliveryState::Interrupted {
338 for ((follow, turn, attempt, kind, _), block) in &self.deliveries {
339 if (*follow, *turn, *attempt) == (send, delivery.turn, delivery.attempt)
340 && !block.text.is_empty()
341 {
342 let label = if *kind == DeliveryKind::Summary {
343 "Thinking · partial"
344 } else {
345 "Partial response"
346 };
347 patches.push(Patch {
348 id: block.id,
349 text: format!("{label} (interrupted)\n{}\n\n", block.text),
350 });
351 }
352 }
353 return patches;
354 }
355 self.active = None;
356 let key = (
357 send,
358 delivery.turn,
359 delivery.attempt,
360 delivery.kind,
361 delivery.block.clone(),
362 );
363 let id = if let Some(block) = self.deliveries.get(&key) {
364 block.id
365 } else {
366 let id = self.slot();
367 self.deliveries.insert(
368 key.clone(),
369 LiveBlock {
370 id,
371 text: String::new(),
372 },
373 );
374 id
375 };
376 let block = self
377 .deliveries
378 .get_mut(&key)
379 .expect("delivery block exists");
380 match delivery.state {
381 DeliveryState::Delta => block.text.push_str(&delivery.text),
382 DeliveryState::Complete | DeliveryState::Interrupted => {
383 block.text = delivery.text.clone()
384 }
385 }
386 let text = block.text.clone();
387 if delivery.kind == DeliveryKind::Summary {
388 self.thinking.insert(id);
389 }
390 if delivery.kind == DeliveryKind::Text {
391 self.delivered_turns.insert((send, delivery.turn));
392 }
393 if delivery.kind == DeliveryKind::Text && delivery.state == DeliveryState::Complete {
394 self.result_candidate = candidate(&text).map(|value| (id, value));
395 self.assistant = Some(crate::types::Sha256Bytes::of(
396 markdown(&text).trim().as_bytes(),
397 ));
398 }
399 if delivery.state == DeliveryState::Complete {
400 self.deliveries
401 .get_mut(&key)
402 .expect("delivery exists")
403 .text
404 .clear();
405 }
406 patches.push(Patch {
407 id,
408 text: format!(
409 "{}{}{}\n\n",
410 if delivery.kind == DeliveryKind::Summary {
411 "Thinking\n"
412 } else {
413 ""
414 },
415 markdown(&text),
416 if delivery.state == DeliveryState::Interrupted {
417 "\n[partial, interrupted]"
418 } else {
419 ""
420 }
421 ),
422 });
423 patches
424 }
425 pub fn is_thinking(&self, id: BlockId) -> bool {
426 self.thinking.contains(&id)
427 }
428 pub fn apply(&mut self, event: &Event) -> Vec<Patch> {
429 let mut patches = Vec::new();
430 let mut dirty = BTreeSet::new();
431 match &event.kind {
432 Kind::Delivery(delivery) => return self.delivery(event.send, delivery),
433 Kind::Review(_) | Kind::Attempt(_) => {}
434 Kind::Send(send) => {
435 self.active = None;
436 self.assistant = None;
437 if let Some(text) = &send.text {
438 self.prose(text, true, &mut patches);
439 self.question = Some((
440 event.send,
441 crate::types::Sha256Bytes::of(text.trim().as_bytes()),
442 ));
443 }
444 }
445 Kind::Message(message) => {
446 if let Some(question) = &message.question {
447 let identity = (
448 event.send,
449 crate::types::Sha256Bytes::of(question.trim().as_bytes()),
450 );
451 if self.question.as_ref() != Some(&identity) {
452 self.prose(question, true, &mut patches);
453 }
454 self.question = None;
455 } else {
456 match &message.message {
457 Message::System { .. } => {}
458 Message::Assistant { content, .. } => {
459 for part in content {
460 match part {
461 AssistantContent::Text(text) => {
462 if !self
463 .delivered_turns
464 .contains(&(event.send, message.turn))
465 {
466 self.prose(&text.text, false, &mut patches);
467 }
468 }
469 AssistantContent::ToolCall(call) => {
470 self.call(event.send, call, &mut dirty)
471 }
472 AssistantContent::Reasoning(_) | AssistantContent::Image(_) => {
473 }
474 }
475 }
476 }
477 Message::User { content } => {
478 let mut result_index = 0;
479 for part in content {
480 match part {
481 UserContent::Text(text) => {
482 self.prose(&text.text, true, &mut patches)
483 }
484 UserContent::ToolResult(result) => {
485 self.result(
486 event.send,
487 result,
488 message.is_error.get(result_index).copied(),
489 &mut dirty,
490 );
491 result_index += 1;
492 }
493 _ => {}
494 }
495 }
496 }
497 }
498 }
499 }
500 Kind::End(end) => {
501 self.stopped(&mut dirty);
502 if let Some(result) = end.result() {
503 let value = result.to_json();
504 let text = result_text(result);
505 if let Some((id, candidate)) = self.result_candidate.take()
506 && same_result(&candidate, &value)
507 {
508 patches.push(Patch {
509 id,
510 text: format!("{text}\n\n"),
511 });
512 } else {
513 self.prose(&text, false, &mut patches);
514 }
515 }
516 if let Some((code, error)) = end.failure() {
517 self.prose(
518 &format!(
519 "Review failed: {}\n{error}",
520 code.map(crate::agent::error::Code::as_str)
521 .unwrap_or_default()
522 ),
523 false,
524 &mut patches,
525 );
526 }
527 }
528 Kind::Answer(answer) => {
529 if let Some(text) = answer.text() {
530 let digest = crate::types::Sha256Bytes::of(markdown(text).trim().as_bytes());
531 if self.assistant != Some(digest) {
532 self.prose(text, false, &mut patches);
533 }
534 }
535 if let Some((code, error)) = answer.failure() {
536 self.stopped(&mut dirty);
537 self.prose(
538 &format!(
539 "Follow-up failed: {}\n{error}",
540 code.map(crate::agent::error::Code::as_str)
541 .unwrap_or_default()
542 ),
543 false,
544 &mut patches,
545 );
546 }
547 }
548 }
549 for id in dirty {
550 patches.push(Patch {
551 id,
552 text: self.groups[&id].text(self.expanded.contains(&id)),
553 });
554 }
555 patches.sort_by_key(|patch| patch.id);
556 patches
557 }
558 pub fn expansion(&mut self, expanded: &BTreeSet<BlockId>) -> Vec<Patch> {
559 let changed = self
560 .expanded
561 .symmetric_difference(expanded)
562 .copied()
563 .collect::<Vec<_>>();
564 self.expanded = expanded.clone();
565 changed
566 .into_iter()
567 .filter_map(|id| {
568 self.groups.get(&id).map(|group| Patch {
569 id,
570 text: group.text(self.expanded.contains(&id)),
571 })
572 })
573 .collect()
574 }
575 pub fn is_group(&self, id: BlockId) -> bool {
576 self.groups.contains_key(&id)
577 }
578}
579
580fn header(text: &str) -> String {
581 text.chars()
582 .map(|character| {
583 if character.is_control() {
584 ' '
585 } else {
586 character
587 }
588 })
589 .take(HEADER_CHARS)
590 .collect()
591}
592fn activity_label(name: &str) -> &str {
593 if name == "read" || name.starts_with("read_") {
594 "Read"
595 } else if name == "grep"
596 || name == "glob"
597 || name.starts_with("grep_")
598 || name.starts_with("glob_")
599 {
600 "Search"
601 } else if name == "list" || name.starts_with("list_") {
602 "List"
603 } else if name == "view_image" || name.starts_with("view_image_") {
604 "View image"
605 } else {
606 name
607 }
608}
609fn target(call: &ToolCall) -> String {
610 let name = call.function.name.as_str();
611 let builtin = ["read", "list", "glob", "grep", "view_image"]
612 .iter()
613 .any(|base| {
614 name == *base
615 || name
616 .strip_prefix(base)
617 .is_some_and(|rest| rest.starts_with('_'))
618 });
619 if !builtin {
620 return "target not recorded".into();
621 }
622 let args = &call.function.arguments;
623 let path = args
624 .get("path")
625 .and_then(serde_json::Value::as_str)
626 .map(header)
627 .filter(|text| !text.is_empty())
628 .unwrap_or_else(|| ".".into());
629 match args.get("pattern").and_then(serde_json::Value::as_str) {
630 Some(pattern) => format!("{path} · {}", header(pattern)),
631 None => path,
632 }
633}
634fn failure_reason(result: &ToolResult) -> String {
635 result
636 .content
637 .iter()
638 .find_map(|part| match part {
639 ToolResultContent::Text(text) => Some(header(&text.text)),
640 _ => None,
641 })
642 .filter(|text| !text.is_empty())
643 .unwrap_or_else(|| "failure reason not recorded".into())
644}
645
646fn result_text(result: &crate::agent::verdict::ValidatedResult) -> String {
649 fn field(label: &str, value: &serde_json::Value, indent: usize, lines: &mut Vec<String>) {
650 let prefix = " ".repeat(indent);
651 match value {
652 serde_json::Value::Object(fields) => {
653 if !label.is_empty() {
654 lines.push(format!("{prefix}{label}"));
655 }
656 for (name, value) in fields {
657 field(name, value, indent + usize::from(!label.is_empty()), lines);
658 }
659 }
660 serde_json::Value::Array(items) => {
661 if !label.is_empty() {
662 lines.push(format!("{prefix}{label}"));
663 }
664 for value in items {
665 field("-", value, indent + 1, lines);
666 }
667 }
668 serde_json::Value::String(text) => lines.push(format!(
669 "{prefix}{}{}",
670 if label.is_empty() {
671 String::new()
672 } else {
673 format!("{label}: ")
674 },
675 markdown(text)
676 )),
677 value => lines.push(format!("{prefix}{label}: {value}")),
678 }
679 }
680 let mut lines = vec![format!("Verdict: {}", header(result.verdict.as_str()))];
681 for (name, value) in &result.fields {
682 field(name, value, 0, &mut lines);
683 }
684 lines.join("\n")
685}
686
687pub fn markdown(text: &str) -> String {
690 let mut code = false;
691 text.lines()
692 .map(|line| {
693 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
694 code = !code;
695 return String::new();
696 }
697 if code {
698 return format!(" {line}");
699 }
700 let heading = line.trim_start_matches('#');
701 if heading.len() != line.len() && heading.starts_with(' ') {
702 heading.trim_start().to_owned()
703 } else {
704 line.to_owned()
705 }
706 })
707 .collect::<Vec<_>>()
708 .join("\n")
709}