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 its authoritative End arrives.
168    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}
182/// Provider envelopes, null placeholders and JSON-encoded fields are presentation details.
183/// Only a matching validated End replaces the raw answer; examples and failed answers stay.
184fn 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        // Repeated recorded IDs do not invent another execution or attach to a later call.
273        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
646/// Owner result fields are meaningful data, not provider transport. Render them recursively
647/// as labels and text instead of JSON syntax; unknown owner fields remain visible too.
648fn 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
687/// Lightweight terminal Markdown: keep literal paragraphs/lists and code indentation, remove
688/// heading/fence syntax only. No HTML, links, images or private payloads are interpreted.
689pub 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}