Skip to main content

artifactize_core/agent/session/
transcript.rs

1//! Semantic, minimal session presentation. Stored events remain lossless; this view keeps only
2//! prose and bounded activity headers, never tool output, arguments, reasoning or media payloads.
3use super::{Delivery, DeliveryKind, DeliveryState, Event, Kind};
4use rig_core::message::{
5    AssistantContent, Message, ToolCall, ToolResult, ToolResultContent, UserContent,
6};
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Activity labels must stay small even when a custom tool or error has very large text.
10const 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        // Failures remain visible even when collapsed. Only recorded failure flags qualify.
96        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
135/// Disk-document edits use stable slots. A group update replaces only that group's text;
136/// prose strings leave this layer immediately and are retained by the existing private pages.
137pub struct Patch {
138    pub id: BlockId,
139    pub text: String,
140}
141
142struct LiveBlock {
143    id: BlockId,
144    text: String,
145}
146
147/// The id a tool call and its result share on the wire, which links the two: built only
148/// from a recorded call identity.
149#[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    /// A JSON-looking assistant message is unchanged until an identical authoritative End arrives.
168    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        // Repeated recorded IDs do not invent another execution or attach to a later call.
229        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
616/// Owner result fields are meaningful data, not provider transport. Render them recursively
617/// as labels and text instead of JSON syntax; unknown owner fields remain visible too.
618fn 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
657/// Lightweight terminal Markdown: keep literal paragraphs/lists and code indentation, remove
658/// heading/fence syntax only. No HTML, links, images or private payloads are interpreted.
659pub 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}