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, crate::types::Sha256Bytes)>,
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}
182impl Transcript {
183 fn slot(&mut self) -> BlockId {
184 let id = BlockId(self.blocks);
185 self.blocks += 1;
186 id
187 }
188 fn prose(&mut self, text: &str, user: bool, patches: &mut Vec<Patch>) {
189 if text.trim().is_empty() {
190 return;
191 }
192 self.active = None;
193 let candidate = if !user {
194 serde_json::from_str::<serde_json::Value>(text)
195 .ok()
196 .filter(|value| {
197 value
198 .get("verdict")
199 .and_then(serde_json::Value::as_str)
200 .is_some_and(|verdict| matches!(verdict, "GREEN" | "RED"))
201 })
202 .map(|value| crate::types::Sha256Bytes::of(value.to_string().as_bytes()))
203 } else {
204 None
205 };
206 let text = markdown(text);
207 if !user {
208 self.assistant = Some(crate::types::Sha256Bytes::of(text.trim().as_bytes()));
209 }
210 let id = self.slot();
211 self.result_candidate = candidate.map(|digest| (id, digest));
212 patches.push(Patch {
213 id,
214 text: format!("{}{text}\n\n", if user { "You\n" } else { "" }),
215 });
216 }
217 fn group(&mut self) -> BlockId {
218 if let Some(id) = self.active {
219 return id;
220 }
221 let id = self.slot();
222 self.groups.insert(id, Group::default());
223 self.active = Some(id);
224 id
225 }
226 fn call(&mut self, send: Option<usize>, call: &ToolCall, dirty: &mut BTreeSet<BlockId>) {
227 let key = (send, Correlation::of(&call.id));
228 if self.calls.contains_key(&key) {
230 return;
231 }
232 let id = self.group();
233 let group = self.groups.get_mut(&id).expect("active group exists");
234 let index = group.activities.len();
235 group.activities.push(Activity {
236 name: header(call.function.name.as_str()),
237 target: target(call),
238 state: State::Running,
239 orphan: false,
240 });
241 self.calls.insert(key, (id, index));
242 dirty.insert(id);
243 }
244 fn result(
245 &mut self,
246 send: Option<usize>,
247 result: &ToolResult,
248 failed: Option<bool>,
249 dirty: &mut BTreeSet<BlockId>,
250 ) {
251 let key = (send, Correlation::of(&result.call));
252 let state = match failed {
253 Some(true) => State::Failed(failure_reason(result)),
254 Some(false) => State::Succeeded,
255 None => State::CompletedUnknown,
256 };
257 let (id, index) = match self.calls.get(&key).copied() {
258 Some(link) => link,
259 None => {
260 let id = self.group();
261 let group = self.groups.get_mut(&id).expect("active group exists");
262 let index = group.activities.len();
263 group.activities.push(Activity {
264 name: header(result.name.as_str()),
265 target: "target not recorded".into(),
266 state: state.clone(),
267 orphan: true,
268 });
269 self.calls.insert(key, (id, index));
270 (id, index)
271 }
272 };
273 self.groups
274 .get_mut(&id)
275 .expect("linked group exists")
276 .activities[index]
277 .state = state;
278 dirty.insert(id);
279 }
280 fn stopped(&mut self, dirty: &mut BTreeSet<BlockId>) {
281 for (id, group) in &mut self.groups {
282 for activity in &mut group.activities {
283 if activity.state == State::Running {
284 activity.state = State::Unfinished;
285 dirty.insert(*id);
286 }
287 }
288 }
289 self.active = None;
290 }
291 fn delivery(&mut self, send: Option<usize>, delivery: &Delivery) -> Vec<Patch> {
292 let mut patches = Vec::new();
293 if delivery.block.is_attempt() && delivery.state == DeliveryState::Interrupted {
294 for ((follow, turn, attempt, kind, _), block) in &self.deliveries {
295 if (*follow, *turn, *attempt) == (send, delivery.turn, delivery.attempt)
296 && !block.text.is_empty()
297 {
298 let label = if *kind == DeliveryKind::Summary {
299 "Thinking · partial"
300 } else {
301 "Partial response"
302 };
303 patches.push(Patch {
304 id: block.id,
305 text: format!("{label} (interrupted)\n{}\n\n", block.text),
306 });
307 }
308 }
309 return patches;
310 }
311 self.active = None;
312 let key = (
313 send,
314 delivery.turn,
315 delivery.attempt,
316 delivery.kind,
317 delivery.block.clone(),
318 );
319 let id = if let Some(block) = self.deliveries.get(&key) {
320 block.id
321 } else {
322 let id = self.slot();
323 self.deliveries.insert(
324 key.clone(),
325 LiveBlock {
326 id,
327 text: String::new(),
328 },
329 );
330 id
331 };
332 let block = self
333 .deliveries
334 .get_mut(&key)
335 .expect("delivery block exists");
336 match delivery.state {
337 DeliveryState::Delta => block.text.push_str(&delivery.text),
338 DeliveryState::Complete | DeliveryState::Interrupted => {
339 block.text = delivery.text.clone()
340 }
341 }
342 let text = block.text.clone();
343 if delivery.kind == DeliveryKind::Summary {
344 self.thinking.insert(id);
345 }
346 if delivery.kind == DeliveryKind::Text {
347 self.delivered_turns.insert((send, delivery.turn));
348 }
349 if delivery.kind == DeliveryKind::Text && delivery.state == DeliveryState::Complete {
350 let value = serde_json::from_str::<serde_json::Value>(&text)
351 .ok()
352 .filter(|value| {
353 value
354 .get("verdict")
355 .and_then(serde_json::Value::as_str)
356 .is_some_and(|verdict| matches!(verdict, "GREEN" | "RED"))
357 });
358 self.result_candidate = value.map(|value| {
359 (
360 id,
361 crate::types::Sha256Bytes::of(value.to_string().as_bytes()),
362 )
363 });
364 self.assistant = Some(crate::types::Sha256Bytes::of(
365 markdown(&text).trim().as_bytes(),
366 ));
367 }
368 if delivery.state == DeliveryState::Complete {
369 self.deliveries
370 .get_mut(&key)
371 .expect("delivery exists")
372 .text
373 .clear();
374 }
375 patches.push(Patch {
376 id,
377 text: format!(
378 "{}{}{}\n\n",
379 if delivery.kind == DeliveryKind::Summary {
380 "Thinking\n"
381 } else {
382 ""
383 },
384 markdown(&text),
385 if delivery.state == DeliveryState::Interrupted {
386 "\n[partial, interrupted]"
387 } else {
388 ""
389 }
390 ),
391 });
392 patches
393 }
394 pub fn is_thinking(&self, id: BlockId) -> bool {
395 self.thinking.contains(&id)
396 }
397 pub fn apply(&mut self, event: &Event) -> Vec<Patch> {
398 let mut patches = Vec::new();
399 let mut dirty = BTreeSet::new();
400 match &event.kind {
401 Kind::Delivery(delivery) => return self.delivery(event.send, delivery),
402 Kind::Review(_) | Kind::Attempt(_) => {}
403 Kind::Send(send) => {
404 self.active = None;
405 self.assistant = None;
406 if let Some(text) = &send.text {
407 self.prose(text, true, &mut patches);
408 self.question = Some((
409 event.send,
410 crate::types::Sha256Bytes::of(text.trim().as_bytes()),
411 ));
412 }
413 }
414 Kind::Message(message) => {
415 if let Some(question) = &message.question {
416 let identity = (
417 event.send,
418 crate::types::Sha256Bytes::of(question.trim().as_bytes()),
419 );
420 if self.question.as_ref() != Some(&identity) {
421 self.prose(question, true, &mut patches);
422 }
423 self.question = None;
424 } else {
425 match &message.message {
426 Message::System { .. } => {}
427 Message::Assistant { content, .. } => {
428 for part in content {
429 match part {
430 AssistantContent::Text(text) => {
431 if !self
432 .delivered_turns
433 .contains(&(event.send, message.turn))
434 {
435 self.prose(&text.text, false, &mut patches);
436 }
437 }
438 AssistantContent::ToolCall(call) => {
439 self.call(event.send, call, &mut dirty)
440 }
441 AssistantContent::Reasoning(_) | AssistantContent::Image(_) => {
442 }
443 }
444 }
445 }
446 Message::User { content } => {
447 let mut result_index = 0;
448 for part in content {
449 match part {
450 UserContent::Text(text) => {
451 self.prose(&text.text, true, &mut patches)
452 }
453 UserContent::ToolResult(result) => {
454 self.result(
455 event.send,
456 result,
457 message.is_error.get(result_index).copied(),
458 &mut dirty,
459 );
460 result_index += 1;
461 }
462 _ => {}
463 }
464 }
465 }
466 }
467 }
468 }
469 Kind::End(end) => {
470 self.stopped(&mut dirty);
471 if let Some(result) = end.result() {
472 let digest =
473 crate::types::Sha256Bytes::of(result.to_json().to_string().as_bytes());
474 let text = result_text(result);
475 if let Some((id, candidate)) = self.result_candidate.take()
476 && candidate == digest
477 {
478 patches.push(Patch {
479 id,
480 text: format!("{text}\n\n"),
481 });
482 } else {
483 self.prose(&text, false, &mut patches);
484 }
485 }
486 if let Some((code, error)) = end.failure() {
487 self.prose(
488 &format!(
489 "Review failed: {}\n{error}",
490 code.map(crate::agent::error::Code::as_str)
491 .unwrap_or_default()
492 ),
493 false,
494 &mut patches,
495 );
496 }
497 }
498 Kind::Answer(answer) => {
499 if let Some(text) = answer.text() {
500 let digest = crate::types::Sha256Bytes::of(markdown(text).trim().as_bytes());
501 if self.assistant != Some(digest) {
502 self.prose(text, false, &mut patches);
503 }
504 }
505 if let Some((code, error)) = answer.failure() {
506 self.stopped(&mut dirty);
507 self.prose(
508 &format!(
509 "Follow-up failed: {}\n{error}",
510 code.map(crate::agent::error::Code::as_str)
511 .unwrap_or_default()
512 ),
513 false,
514 &mut patches,
515 );
516 }
517 }
518 }
519 for id in dirty {
520 patches.push(Patch {
521 id,
522 text: self.groups[&id].text(self.expanded.contains(&id)),
523 });
524 }
525 patches.sort_by_key(|patch| patch.id);
526 patches
527 }
528 pub fn expansion(&mut self, expanded: &BTreeSet<BlockId>) -> Vec<Patch> {
529 let changed = self
530 .expanded
531 .symmetric_difference(expanded)
532 .copied()
533 .collect::<Vec<_>>();
534 self.expanded = expanded.clone();
535 changed
536 .into_iter()
537 .filter_map(|id| {
538 self.groups.get(&id).map(|group| Patch {
539 id,
540 text: group.text(self.expanded.contains(&id)),
541 })
542 })
543 .collect()
544 }
545 pub fn is_group(&self, id: BlockId) -> bool {
546 self.groups.contains_key(&id)
547 }
548}
549
550fn header(text: &str) -> String {
551 text.chars()
552 .map(|character| {
553 if character.is_control() {
554 ' '
555 } else {
556 character
557 }
558 })
559 .take(HEADER_CHARS)
560 .collect()
561}
562fn activity_label(name: &str) -> &str {
563 if name == "read" || name.starts_with("read_") {
564 "Read"
565 } else if name == "grep"
566 || name == "glob"
567 || name.starts_with("grep_")
568 || name.starts_with("glob_")
569 {
570 "Search"
571 } else if name == "list" || name.starts_with("list_") {
572 "List"
573 } else if name == "view_image" || name.starts_with("view_image_") {
574 "View image"
575 } else {
576 name
577 }
578}
579fn target(call: &ToolCall) -> String {
580 let name = call.function.name.as_str();
581 let builtin = ["read", "list", "glob", "grep", "view_image"]
582 .iter()
583 .any(|base| {
584 name == *base
585 || name
586 .strip_prefix(base)
587 .is_some_and(|rest| rest.starts_with('_'))
588 });
589 if !builtin {
590 return "target not recorded".into();
591 }
592 let args = &call.function.arguments;
593 let path = args
594 .get("path")
595 .and_then(serde_json::Value::as_str)
596 .map(header)
597 .filter(|text| !text.is_empty())
598 .unwrap_or_else(|| ".".into());
599 match args.get("pattern").and_then(serde_json::Value::as_str) {
600 Some(pattern) => format!("{path} · {}", header(pattern)),
601 None => path,
602 }
603}
604fn failure_reason(result: &ToolResult) -> String {
605 result
606 .content
607 .iter()
608 .find_map(|part| match part {
609 ToolResultContent::Text(text) => Some(header(&text.text)),
610 _ => None,
611 })
612 .filter(|text| !text.is_empty())
613 .unwrap_or_else(|| "failure reason not recorded".into())
614}
615
616fn result_text(result: &crate::agent::verdict::ValidatedResult) -> String {
619 fn field(label: &str, value: &serde_json::Value, indent: usize, lines: &mut Vec<String>) {
620 let prefix = " ".repeat(indent);
621 match value {
622 serde_json::Value::Object(fields) => {
623 if !label.is_empty() {
624 lines.push(format!("{prefix}{label}"));
625 }
626 for (name, value) in fields {
627 field(name, value, indent + usize::from(!label.is_empty()), lines);
628 }
629 }
630 serde_json::Value::Array(items) => {
631 if !label.is_empty() {
632 lines.push(format!("{prefix}{label}"));
633 }
634 for value in items {
635 field("-", value, indent + 1, lines);
636 }
637 }
638 serde_json::Value::String(text) => lines.push(format!(
639 "{prefix}{}{}",
640 if label.is_empty() {
641 String::new()
642 } else {
643 format!("{label}: ")
644 },
645 markdown(text)
646 )),
647 value => lines.push(format!("{prefix}{label}: {value}")),
648 }
649 }
650 let mut lines = vec![format!("Verdict: {}", header(result.verdict.as_str()))];
651 for (name, value) in &result.fields {
652 field(name, value, 0, &mut lines);
653 }
654 lines.join("\n")
655}
656
657pub fn markdown(text: &str) -> String {
660 let mut code = false;
661 text.lines()
662 .map(|line| {
663 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
664 code = !code;
665 return String::new();
666 }
667 if code {
668 return format!(" {line}");
669 }
670 let heading = line.trim_start_matches('#');
671 if heading.len() != line.len() && heading.starts_with(' ') {
672 heading.trim_start().to_owned()
673 } else {
674 line.to_owned()
675 }
676 })
677 .collect::<Vec<_>>()
678 .join("\n")
679}