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