Skip to main content

oino_view/
transcript.rs

1//! Transcript rows (the interactive-mode chat container).
2//!
3//! [`Transcript`] mirrors what the interactive mode puts into its chat container: user
4//! messages (with skill blocks split out), assistant messages (text + thinking), one row per
5//! tool call (args stream in, results attach by tool-call id), `!` command runs, custom
6//! messages, custom entries, branch/compaction summaries and notices. Rows carry a revision
7//! counter so frontends can cache rendered output.
8
9use std::collections::{HashMap, HashSet};
10
11use oino_agent::AgentToolResult;
12use oino_ai::{
13    AgentMessage, AssistantContent, AssistantMessage, ImageContent, JsonObject, JsonValue,
14    StopReason, UserContent, UserContentField,
15};
16use oino_core::events::AgentSessionEvent;
17use oino_session::{EntryId, EntryIndex, EntryKind, SessionEntry};
18
19/// Stable id of a row inside one [`Transcript`].
20pub type RowId = u64;
21
22/// One transcript row.
23#[derive(Clone, Debug, PartialEq)]
24pub struct Row {
25    /// Stable id.
26    pub id: RowId,
27    /// Bumped on every change (render cache key).
28    pub rev: u64,
29    /// Content.
30    pub kind: RowKind,
31}
32
33/// Row content.
34#[derive(Clone, Debug, PartialEq)]
35pub enum RowKind {
36    /// User message.
37    User(UserRow),
38    /// Skill invocation block (`<skill name=… location=…>`), collapsible.
39    Skill(SkillRow),
40    /// Assistant message (text and thinking blocks).
41    Assistant(AssistantRow),
42    /// One tool call.
43    Tool(ToolRow),
44    /// A `!`/`!!` command.
45    Bash(BashRow),
46    /// Extension message with `display: true`.
47    Custom(CustomRow),
48    /// Extension entry (`custom`); rendered only when a renderer yields content.
49    CustomEntry(CustomEntryRow),
50    /// Branch summary.
51    BranchSummary(SummaryRow),
52    /// Compaction summary.
53    Compaction(SummaryRow),
54    /// Status/warning/error line added by the frontend.
55    Notice(NoticeRow),
56}
57
58/// User message row.
59#[derive(Clone, Debug, PartialEq)]
60pub struct UserRow {
61    /// Text blocks joined.
62    pub text: String,
63    /// Attached images.
64    pub images: Vec<ImageContent>,
65    /// Session entry id that started the turn (ui-protocol §6.7 `turnEntryId`).
66    pub entry_id: Option<String>,
67}
68
69/// `parseSkillBlock` result.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SkillRow {
72    /// Skill name.
73    pub name: String,
74    /// `SKILL.md` location.
75    pub location: String,
76    /// Skill body.
77    pub content: String,
78}
79
80/// One block of an assistant message.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum AssistantBlock {
83    /// Markdown text.
84    Text(String),
85    /// Consecutive thinking blocks joined with a blank line.
86    Thinking(String),
87}
88
89/// Assistant message row.
90#[derive(Clone, Debug, PartialEq)]
91pub struct AssistantRow {
92    /// Visible blocks in order.
93    pub blocks: Vec<AssistantBlock>,
94    /// The message contains tool calls (errors are shown on the tool rows then).
95    pub has_tool_calls: bool,
96    /// Stop reason (`None` while streaming).
97    pub stop_reason: Option<StopReason>,
98    /// Provider error text.
99    pub error_message: Option<String>,
100    /// Still streaming.
101    pub streaming: bool,
102}
103
104/// Result attached to a tool row.
105#[derive(Clone, Debug, PartialEq)]
106pub struct ToolOutput {
107    /// Text and image blocks.
108    pub content: Vec<UserContent>,
109    /// Tool-specific details (`diff`, `truncation`, …).
110    pub details: Option<JsonValue>,
111    /// Error flag.
112    pub is_error: bool,
113}
114
115impl ToolOutput {
116    /// Text blocks joined with newlines.
117    #[must_use]
118    pub fn text(&self) -> String {
119        self.content
120            .iter()
121            .filter_map(|c| match c {
122                UserContent::Text(t) => Some(t.text.as_str()),
123                UserContent::Image(_) => None,
124            })
125            .collect::<Vec<_>>()
126            .join("\n")
127    }
128
129    /// Image blocks.
130    #[must_use]
131    pub fn images(&self) -> Vec<&ImageContent> {
132        self.content
133            .iter()
134            .filter_map(|c| match c {
135                UserContent::Image(i) => Some(i),
136                UserContent::Text(_) => None,
137            })
138            .collect()
139    }
140
141    fn from_result(result: &AgentToolResult, is_error: bool) -> Self {
142        Self {
143            content: result.content.clone(),
144            details: result.details.clone(),
145            is_error,
146        }
147    }
148}
149
150/// Tool call row.
151#[derive(Clone, Debug, PartialEq)]
152pub struct ToolRow {
153    /// Tool-call id.
154    pub tool_call_id: String,
155    /// Tool name.
156    pub name: String,
157    /// Arguments (partial while streaming).
158    pub args: JsonObject,
159    /// Arguments finished streaming.
160    pub args_complete: bool,
161    /// Execution started.
162    pub started: bool,
163    /// Latest (partial or final) result.
164    pub output: Option<ToolOutput>,
165    /// `output` is a partial update.
166    pub partial: bool,
167    /// Wall time of the finished call.
168    pub duration_ms: Option<u64>,
169}
170
171/// `!` command row.
172#[derive(Clone, Debug, PartialEq, Eq)]
173#[allow(
174    clippy::struct_excessive_bools,
175    reason = "mirrors the BashExecutionMessage flags plus the running state"
176)]
177pub struct BashRow {
178    /// Request id used for `bash_execution_update` routing.
179    pub request_id: Option<String>,
180    /// Command line.
181    pub command: String,
182    /// Output so far.
183    pub output: String,
184    /// Exit code (when finished and not killed).
185    pub exit_code: Option<i32>,
186    /// Cancelled by the user.
187    pub cancelled: bool,
188    /// Output was truncated.
189    pub truncated: bool,
190    /// Full output file.
191    pub full_output_path: Option<String>,
192    /// `!!`: excluded from context.
193    pub exclude_from_context: bool,
194    /// Still running.
195    pub running: bool,
196}
197
198/// Custom message row.
199#[derive(Clone, Debug, PartialEq)]
200pub struct CustomRow {
201    /// Entry id (when known) for renderer requests.
202    pub entry_id: Option<String>,
203    /// Extension-defined type.
204    pub custom_type: String,
205    /// Content.
206    pub content: UserContentField,
207    /// Details.
208    pub details: Option<JsonValue>,
209}
210
211impl CustomRow {
212    /// Text blocks joined with newlines.
213    #[must_use]
214    pub fn text(&self) -> String {
215        self.content.text("\n")
216    }
217}
218
219/// Custom entry row.
220#[derive(Clone, Debug, PartialEq)]
221pub struct CustomEntryRow {
222    /// Entry id.
223    pub entry_id: String,
224    /// Extension-defined type.
225    pub custom_type: String,
226    /// Payload.
227    pub data: Option<JsonValue>,
228}
229
230/// Branch or compaction summary row.
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct SummaryRow {
233    /// Summary text.
234    pub summary: String,
235    /// Tokens before compaction (compaction only).
236    pub tokens_before: u64,
237}
238
239/// Severity of a notice.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum NoticeLevel {
242    /// Dim status line.
243    Info,
244    /// Warning.
245    Warning,
246    /// Error.
247    Error,
248    /// Markdown document (`/hotkeys`, `/session`).
249    Document,
250}
251
252/// Notice row.
253#[derive(Clone, Debug, PartialEq, Eq)]
254pub struct NoticeRow {
255    /// Severity.
256    pub level: NoticeLevel,
257    /// Text.
258    pub text: String,
259}
260
261/// Finished `!` command (subset of core `BashResult`).
262#[derive(Clone, Debug, Default, PartialEq, Eq)]
263pub struct BashOutcome {
264    /// Final output.
265    pub output: String,
266    /// Exit code.
267    pub exit_code: Option<i32>,
268    /// Cancelled.
269    pub cancelled: bool,
270    /// Truncated.
271    pub truncated: bool,
272    /// Full output file.
273    pub full_output_path: Option<String>,
274}
275
276/// What a reducer step requires from the frontend.
277#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct Applied {
279    /// Rows changed (redraw).
280    pub changed: bool,
281    /// The context changed shape (compaction); reload from `get_entries`.
282    pub reload: bool,
283}
284
285/// The transcript model.
286#[derive(Clone, Debug, Default)]
287pub struct Transcript {
288    rows: Vec<Row>,
289    next_id: RowId,
290    next_rev: u64,
291    /// Tool rows awaiting results: tool-call id → row id.
292    pending_tools: HashMap<String, RowId>,
293    /// The streaming assistant row.
294    streaming: Option<RowId>,
295}
296
297/// P a r s e   S k i l l   B l o c k.
298#[must_use]
299pub fn parse_skill_block(text: &str) -> Option<(SkillRow, Option<String>)> {
300    let rest = text.strip_prefix("<skill name=\"")?;
301    let (name, rest) = rest.split_once('"')?;
302    let rest = rest.strip_prefix(" location=\"")?;
303    let (location, rest) = rest.split_once('"')?;
304    let rest = rest.strip_prefix(">\n")?;
305    let end = rest.find("\n</skill>")?;
306    let content = &rest[..end];
307    let tail = &rest[end + "\n</skill>".len()..];
308    let user = if tail.is_empty() {
309        None
310    } else {
311        let message = tail.strip_prefix("\n\n")?;
312        let trimmed = message.trim();
313        (!trimmed.is_empty()).then(|| trimmed.to_owned())
314    };
315    if name.is_empty() || location.is_empty() {
316        return None;
317    }
318    Some((
319        SkillRow {
320            name: name.to_owned(),
321            location: location.to_owned(),
322            content: content.to_owned(),
323        },
324        user,
325    ))
326}
327
328fn user_text(content: &UserContentField) -> (String, Vec<ImageContent>) {
329    match content {
330        UserContentField::Text(text) => (text.clone(), Vec::new()),
331        UserContentField::Blocks(blocks) => {
332            let mut texts = Vec::new();
333            let mut images = Vec::new();
334            for block in blocks {
335                match block {
336                    UserContent::Text(t) => texts.push(t.text.as_str()),
337                    UserContent::Image(i) => images.push(i.clone()),
338                }
339            }
340            (texts.join(""), images)
341        }
342    }
343}
344
345fn assistant_row(message: &AssistantMessage, streaming: bool) -> AssistantRow {
346    let mut blocks = Vec::new();
347    let mut thinking: Vec<String> = Vec::new();
348    let mut in_thinking_run = false;
349    let flush = |blocks: &mut Vec<AssistantBlock>, thinking: &mut Vec<String>| {
350        if !thinking.is_empty() {
351            blocks.push(AssistantBlock::Thinking(thinking.join("\n\n")));
352            thinking.clear();
353        }
354    };
355    let mut has_tool_calls = false;
356    for content in &message.content {
357        match content {
358            AssistantContent::Thinking(t) => {
359                in_thinking_run = true;
360                let text = t.thinking.trim();
361                if !text.is_empty() {
362                    thinking.push(text.to_owned());
363                }
364            }
365            AssistantContent::Text(t) => {
366                if in_thinking_run {
367                    flush(&mut blocks, &mut thinking);
368                    in_thinking_run = false;
369                }
370                let text = t.text.trim();
371                if !text.is_empty() {
372                    blocks.push(AssistantBlock::Text(text.to_owned()));
373                }
374            }
375            AssistantContent::ToolCall(_) => {
376                has_tool_calls = true;
377                if in_thinking_run {
378                    flush(&mut blocks, &mut thinking);
379                    in_thinking_run = false;
380                }
381            }
382        }
383    }
384    flush(&mut blocks, &mut thinking);
385    AssistantRow {
386        blocks,
387        has_tool_calls,
388        stop_reason: (!streaming).then_some(message.stop_reason),
389        error_message: message.error_message.clone(),
390        streaming,
391    }
392}
393
394/// Error text shown on tool rows of an aborted/failed assistant message.
395fn tool_error_text(message: &AssistantMessage) -> String {
396    if message.stop_reason == StopReason::Aborted {
397        "Operation aborted".to_owned()
398    } else {
399        message
400            .error_message
401            .clone()
402            .unwrap_or_else(|| "Error".to_owned())
403    }
404}
405
406impl Transcript {
407    /// An empty transcript.
408    #[must_use]
409    pub fn new() -> Self {
410        Self::default()
411    }
412
413    /// Rows in display order.
414    #[must_use]
415    pub fn rows(&self) -> &[Row] {
416        &self.rows
417    }
418
419    /// Whether an assistant message is streaming.
420    #[must_use]
421    pub const fn is_streaming(&self) -> bool {
422        self.streaming.is_some()
423    }
424
425    /// Removes every row.
426    pub fn clear(&mut self) {
427        self.rows.clear();
428        self.pending_tools.clear();
429        self.streaming = None;
430    }
431
432    fn push(&mut self, kind: RowKind) -> RowId {
433        let id = self.next_id;
434        self.next_id += 1;
435        self.next_rev += 1;
436        self.rows.push(Row {
437            id,
438            rev: self.next_rev,
439            kind,
440        });
441        id
442    }
443
444    fn index(&self, id: RowId) -> Option<usize> {
445        // Rows are appended with increasing ids; search from the end (recent rows change).
446        self.rows.iter().rposition(|row| row.id == id)
447    }
448
449    fn update(&mut self, id: RowId, f: impl FnOnce(&mut RowKind)) -> bool {
450        let Some(index) = self.index(id) else {
451            return false;
452        };
453        self.next_rev += 1;
454        let row = &mut self.rows[index];
455        row.rev = self.next_rev;
456        f(&mut row.kind);
457        true
458    }
459
460    /// Forces a re-render of every row (theme or density change).
461    pub fn touch_all(&mut self) {
462        for row in &mut self.rows {
463            self.next_rev += 1;
464            row.rev = self.next_rev;
465        }
466    }
467
468    /// Adds a notice line.
469    pub fn push_notice(&mut self, level: NoticeLevel, text: impl Into<String>) -> RowId {
470        self.push(RowKind::Notice(NoticeRow {
471            level,
472            text: text.into(),
473        }))
474    }
475
476    /// Rebuilds from session entries the way `renderInitialMessages` does: the context
477    /// entries of the branch ending at `leaf` (latest compaction honoured), custom entries
478    /// included.
479    pub fn reset_from_entries(&mut self, entries: &[SessionEntry], leaf: Option<&str>) {
480        self.clear();
481        let index = EntryIndex::new(entries.to_vec());
482        let leaf = leaf.map(EntryId::new);
483        let mut context = oino_session::build_context_entries(&index, leaf.as_ref());
484        if !context
485            .first()
486            .is_some_and(|e| matches!(e.kind, EntryKind::Compaction(_)))
487        {
488            self.render_entries(&context);
489            return;
490        }
491        // The latest compaction is prepended for the model; frontends show kept entries,
492        // then the summary, then what followed the compaction.
493        let compaction = context.remove(0);
494        let path = oino_session::path_to(&index, leaf.as_ref());
495        let after: HashSet<&EntryId> = path
496            .iter()
497            .position(|e| e.id == compaction.id)
498            .map(|p| path[p + 1..].iter().map(|e| &e.id).collect())
499            .unwrap_or_default();
500        let (before, later): (Vec<_>, Vec<_>) =
501            context.into_iter().partition(|e| !after.contains(&e.id));
502        self.render_entries(&before);
503        self.render_entries(std::slice::from_ref(&compaction));
504        self.render_entries(&later);
505    }
506
507    fn render_entries(&mut self, entries: &[SessionEntry]) {
508        let mut rendered_tools: HashMap<String, RowId> = HashMap::new();
509        for entry in entries {
510            if let EntryKind::Custom { custom_type, data } = &entry.kind {
511                self.push(RowKind::CustomEntry(CustomEntryRow {
512                    entry_id: entry.id.as_str().to_owned(),
513                    custom_type: custom_type.clone(),
514                    data: data.clone(),
515                }));
516                continue;
517            }
518            for message in oino_session::entry_to_context_messages(entry, None) {
519                match &message {
520                    AgentMessage::Assistant(assistant) => {
521                        self.push(RowKind::Assistant(assistant_row(assistant, false)));
522                        let failed = matches!(
523                            assistant.stop_reason,
524                            StopReason::Aborted | StopReason::Error
525                        );
526                        for content in &assistant.content {
527                            if let AssistantContent::ToolCall(call) = content {
528                                let output = failed.then(|| ToolOutput {
529                                    content: vec![UserContent::text(tool_error_text(assistant))],
530                                    details: None,
531                                    is_error: true,
532                                });
533                                let id = self.push(RowKind::Tool(ToolRow {
534                                    tool_call_id: call.id.clone(),
535                                    name: call.name.clone(),
536                                    args: call.arguments.clone(),
537                                    args_complete: true,
538                                    started: true,
539                                    output,
540                                    partial: false,
541                                    duration_ms: None,
542                                }));
543                                if !failed {
544                                    rendered_tools.insert(call.id.clone(), id);
545                                }
546                            }
547                        }
548                    }
549                    AgentMessage::ToolResult(result) => {
550                        if let Some(id) = rendered_tools.remove(&result.tool_call_id) {
551                            let output = ToolOutput {
552                                content: result.content.clone(),
553                                details: result.details.clone(),
554                                is_error: result.is_error,
555                            };
556                            let duration = result.duration_ms;
557                            self.update(id, |kind| {
558                                if let RowKind::Tool(tool) = kind {
559                                    tool.output = Some(output);
560                                    tool.partial = false;
561                                    tool.duration_ms = duration;
562                                }
563                            });
564                        }
565                    }
566                    other => self.add_message(other, Some(entry.id.as_str())),
567                }
568            }
569        }
570        self.pending_tools.extend(rendered_tools);
571    }
572
573    /// `addMessageToChat` for non-assistant, non-tool-result messages.
574    fn add_message(&mut self, message: &AgentMessage, entry_id: Option<&str>) {
575        match message {
576            AgentMessage::User(user) => {
577                let (text, images) = user_text(&user.content);
578                if text.is_empty() && images.is_empty() {
579                    return;
580                }
581                if let Some((skill, user_message)) = parse_skill_block(&text) {
582                    self.push(RowKind::Skill(skill));
583                    if let Some(user_message) = user_message {
584                        self.push(RowKind::User(UserRow {
585                            text: user_message,
586                            images,
587                            entry_id: entry_id.map(str::to_owned),
588                        }));
589                    }
590                } else {
591                    self.push(RowKind::User(UserRow {
592                        text,
593                        images,
594                        entry_id: entry_id.map(str::to_owned),
595                    }));
596                }
597            }
598            AgentMessage::BashExecution(bash) => {
599                self.push(RowKind::Bash(BashRow {
600                    request_id: None,
601                    command: bash.command.clone(),
602                    output: bash.output.clone(),
603                    exit_code: bash.exit_code,
604                    cancelled: bash.cancelled,
605                    truncated: bash.truncated,
606                    full_output_path: bash.full_output_path.clone(),
607                    exclude_from_context: bash.exclude_from_context.unwrap_or(false),
608                    running: false,
609                }));
610            }
611            AgentMessage::Custom(custom) => {
612                if custom.display {
613                    self.push(RowKind::Custom(CustomRow {
614                        entry_id: entry_id.map(str::to_owned),
615                        custom_type: custom.custom_type.clone(),
616                        content: custom.content.clone(),
617                        details: custom.details.clone(),
618                    }));
619                }
620            }
621            AgentMessage::BranchSummary(summary) => {
622                self.push(RowKind::BranchSummary(SummaryRow {
623                    summary: summary.summary.clone(),
624                    tokens_before: 0,
625                }));
626            }
627            AgentMessage::CompactionSummary(summary) => {
628                self.push(RowKind::Compaction(SummaryRow {
629                    summary: summary.summary.clone(),
630                    tokens_before: summary.tokens_before,
631                }));
632            }
633            AgentMessage::Assistant(assistant) => {
634                self.push(RowKind::Assistant(assistant_row(assistant, false)));
635            }
636            AgentMessage::System(_) | AgentMessage::ToolResult(_) => {}
637        }
638    }
639
640    fn ensure_tool(&mut self, id: &str, name: &str, args: &JsonObject) -> RowId {
641        if let Some(row) = self.pending_tools.get(id).copied()
642            && self.index(row).is_some()
643        {
644            return row;
645        }
646        let row = self.push(RowKind::Tool(ToolRow {
647            tool_call_id: id.to_owned(),
648            name: name.to_owned(),
649            args: args.clone(),
650            args_complete: false,
651            started: false,
652            output: None,
653            partial: true,
654            duration_ms: None,
655        }));
656        self.pending_tools.insert(id.to_owned(), row);
657        row
658    }
659
660    /// Applies one session event (`handleEvent` chat-container effects).
661    #[allow(
662        clippy::too_many_lines,
663        reason = "one arm per session event, mirroring interactive-mode handleEvent"
664    )]
665    pub fn apply(&mut self, event: &AgentSessionEvent) -> Applied {
666        let mut applied = Applied::default();
667        match event {
668            AgentSessionEvent::AgentStart => {
669                self.pending_tools.clear();
670            }
671            AgentSessionEvent::MessageStart { message } => match message {
672                AgentMessage::Assistant(assistant) => {
673                    let id = self.push(RowKind::Assistant(assistant_row(assistant, true)));
674                    self.streaming = Some(id);
675                    applied.changed = true;
676                }
677                AgentMessage::User(_) | AgentMessage::Custom(_) => {
678                    let before = self.rows.len();
679                    self.add_message(message, None);
680                    applied.changed = self.rows.len() != before;
681                }
682                _ => {}
683            },
684            AgentSessionEvent::MessageUpdate { message, .. } => {
685                if let (Some(id), AgentMessage::Assistant(assistant)) = (self.streaming, message) {
686                    let row = assistant_row(assistant, true);
687                    self.update(id, |kind| *kind = RowKind::Assistant(row));
688                    for content in &assistant.content {
689                        if let AssistantContent::ToolCall(call) = content {
690                            let tool = self.ensure_tool(&call.id, &call.name, &call.arguments);
691                            let args = call.arguments.clone();
692                            self.update(tool, |kind| {
693                                if let RowKind::Tool(t) = kind
694                                    && t.args != args
695                                {
696                                    t.args = args;
697                                }
698                            });
699                        }
700                    }
701                    applied.changed = true;
702                }
703            }
704            AgentSessionEvent::MessageEnd { message } => {
705                if let (Some(id), AgentMessage::Assistant(assistant)) = (self.streaming, message) {
706                    let mut row = assistant_row(assistant, false);
707                    if assistant.stop_reason == StopReason::Aborted {
708                        row.error_message = Some("Operation aborted".to_owned());
709                    }
710                    self.update(id, |kind| *kind = RowKind::Assistant(row));
711                    let failed = matches!(
712                        assistant.stop_reason,
713                        StopReason::Aborted | StopReason::Error
714                    );
715                    let pending: Vec<RowId> = self.pending_tools.values().copied().collect();
716                    let text = tool_error_text(assistant);
717                    for tool in pending {
718                        let text = text.clone();
719                        self.update(tool, |kind| {
720                            if let RowKind::Tool(t) = kind {
721                                if failed {
722                                    t.output = Some(ToolOutput {
723                                        content: vec![UserContent::text(text)],
724                                        details: None,
725                                        is_error: true,
726                                    });
727                                    t.partial = false;
728                                } else {
729                                    t.args_complete = true;
730                                }
731                            }
732                        });
733                    }
734                    if failed {
735                        self.pending_tools.clear();
736                    }
737                    self.streaming = None;
738                    applied.changed = true;
739                }
740            }
741            AgentSessionEvent::ToolExecutionStart {
742                tool_call_id,
743                tool_name,
744                args,
745                parent_tool_call_id,
746            } => {
747                if parent_tool_call_id.is_none() {
748                    let row = self.ensure_tool(tool_call_id, tool_name, args);
749                    self.update(row, |kind| {
750                        if let RowKind::Tool(t) = kind {
751                            t.started = true;
752                            t.args_complete = true;
753                        }
754                    });
755                    applied.changed = true;
756                }
757            }
758            AgentSessionEvent::ToolExecutionUpdate {
759                tool_call_id,
760                partial_result,
761                ..
762            } => {
763                if let Some(row) = self.pending_tools.get(tool_call_id).copied() {
764                    let output = ToolOutput::from_result(partial_result, false);
765                    applied.changed = self.update(row, |kind| {
766                        if let RowKind::Tool(t) = kind {
767                            t.output = Some(output);
768                            t.partial = true;
769                        }
770                    });
771                }
772            }
773            AgentSessionEvent::ToolExecutionEnd {
774                tool_call_id,
775                result,
776                is_error,
777                duration_ms,
778                ..
779            } => {
780                if let Some(row) = self.pending_tools.remove(tool_call_id) {
781                    let output = ToolOutput::from_result(result, *is_error);
782                    let duration = *duration_ms;
783                    applied.changed = self.update(row, |kind| {
784                        if let RowKind::Tool(t) = kind {
785                            t.output = Some(output);
786                            t.partial = false;
787                            t.duration_ms = duration;
788                        }
789                    });
790                }
791            }
792            AgentSessionEvent::AgentEnd { .. } => {
793                if let Some(id) = self.streaming.take()
794                    && let Some(index) = self.index(id)
795                {
796                    self.rows.remove(index);
797                    applied.changed = true;
798                }
799                self.pending_tools.clear();
800            }
801            AgentSessionEvent::EntryAppended { entry } => match &entry.kind {
802                EntryKind::Custom { custom_type, data } => {
803                    self.push(RowKind::CustomEntry(CustomEntryRow {
804                        entry_id: entry.id.as_str().to_owned(),
805                        custom_type: custom_type.clone(),
806                        data: data.clone(),
807                    }));
808                    applied.changed = true;
809                }
810                EntryKind::CustomMessage {
811                    custom_type,
812                    content,
813                    display: true,
814                    details,
815                } => {
816                    self.push(RowKind::Custom(CustomRow {
817                        entry_id: Some(entry.id.as_str().to_owned()),
818                        custom_type: custom_type.clone(),
819                        content: content.clone(),
820                        details: details.clone(),
821                    }));
822                    applied.changed = true;
823                }
824                EntryKind::Compaction(_) => applied.reload = true,
825                _ => {}
826            },
827            AgentSessionEvent::CompactionEnd {
828                result: Some(_),
829                aborted: false,
830                ..
831            } => applied.reload = true,
832            _ => {}
833        }
834        applied
835    }
836
837    /// Starts a `!` command row (frontend-initiated; output streams via
838    /// [`Transcript::bash_delta`]).
839    pub fn begin_bash(
840        &mut self,
841        request_id: Option<String>,
842        command: impl Into<String>,
843        exclude_from_context: bool,
844    ) -> RowId {
845        self.push(RowKind::Bash(BashRow {
846            request_id,
847            command: command.into(),
848            output: String::new(),
849            exit_code: None,
850            cancelled: false,
851            truncated: false,
852            full_output_path: None,
853            exclude_from_context,
854            running: true,
855        }))
856    }
857
858    fn bash_row(&self, request_id: Option<&str>) -> Option<RowId> {
859        self.rows.iter().rev().find_map(|row| match &row.kind {
860            RowKind::Bash(b) if b.running && b.request_id.as_deref() == request_id => Some(row.id),
861            _ => None,
862        })
863    }
864
865    /// Appends streamed output of a running `!` command.
866    pub fn bash_delta(&mut self, request_id: Option<&str>, delta: &str) -> bool {
867        let Some(id) = self.bash_row(request_id) else {
868            return false;
869        };
870        self.update(id, |kind| {
871            if let RowKind::Bash(b) = kind {
872                b.output.push_str(delta);
873            }
874        })
875    }
876
877    /// Completes a `!` command row.
878    pub fn finish_bash(&mut self, request_id: Option<&str>, outcome: BashOutcome) -> bool {
879        let Some(id) = self.bash_row(request_id) else {
880            return false;
881        };
882        self.update(id, |kind| {
883            if let RowKind::Bash(b) = kind {
884                b.output = outcome.output;
885                b.exit_code = outcome.exit_code;
886                b.cancelled = outcome.cancelled;
887                b.truncated = outcome.truncated;
888                b.full_output_path = outcome.full_output_path;
889                b.running = false;
890            }
891        })
892    }
893
894    /// Marks a running `!` command row as failed with `error`.
895    pub fn fail_bash(&mut self, request_id: Option<&str>, error: &str) -> bool {
896        let Some(id) = self.bash_row(request_id) else {
897            return false;
898        };
899        let error = error.to_owned();
900        self.update(id, |kind| {
901            if let RowKind::Bash(b) = kind {
902                if !b.output.is_empty() && !b.output.ends_with('\n') {
903                    b.output.push('\n');
904                }
905                b.output.push_str(&error);
906                b.running = false;
907            }
908        })
909    }
910
911    /// Text of the last finished assistant message.
912    #[must_use]
913    pub fn last_assistant_text(&self) -> Option<String> {
914        self.rows.iter().rev().find_map(|row| match &row.kind {
915            RowKind::Assistant(a) if !a.streaming => {
916                let text: Vec<&str> = a
917                    .blocks
918                    .iter()
919                    .filter_map(|b| match b {
920                        AssistantBlock::Text(t) => Some(t.as_str()),
921                        AssistantBlock::Thinking(_) => None,
922                    })
923                    .collect();
924                (!text.is_empty()).then(|| text.join("\n\n"))
925            }
926            _ => None,
927        })
928    }
929
930    /// Texts of user rows in order (editor history seed, `populateHistory`).
931    #[must_use]
932    pub fn user_texts(&self) -> Vec<String> {
933        self.rows
934            .iter()
935            .filter_map(|row| match &row.kind {
936                RowKind::User(u) if !u.text.is_empty() => Some(u.text.clone()),
937                _ => None,
938            })
939            .collect()
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use oino_ai::{TextContent, ToolCall, Usage, UserMessage};
947    use serde_json::json;
948
949    fn assistant(content: &[AssistantContent], stop: StopReason) -> AssistantMessage {
950        serde_json::from_value(json!({
951            "content": serde_json::to_value(content).unwrap(),
952            "api": "faux", "provider": "faux", "model": "m",
953            "usage": serde_json::to_value(Usage::default()).unwrap(),
954            "stopReason": stop, "timestamp": 0
955        }))
956        .unwrap()
957    }
958
959    fn call(id: &str, name: &str, args: JsonValue) -> AssistantContent {
960        AssistantContent::ToolCall(ToolCall {
961            id: id.into(),
962            name: name.into(),
963            arguments: serde_json::from_value(args).unwrap(),
964            thought_signature: None,
965            namespace: None,
966            arguments_error: None,
967        })
968    }
969
970    #[test]
971    fn skill_block_parsing() {
972        let (skill, user) = parse_skill_block(
973            "<skill name=\"git\" location=\"/s/SKILL.md\">\nbody\n</skill>\n\nhi",
974        )
975        .unwrap();
976        assert_eq!(skill.name, "git");
977        assert_eq!(skill.content, "body");
978        assert_eq!(user.as_deref(), Some("hi"));
979        assert!(parse_skill_block("plain").is_none());
980    }
981
982    #[test]
983    fn streaming_reply_with_tool() {
984        let mut t = Transcript::new();
985        let user = AgentMessage::User(UserMessage {
986            content: UserContentField::Text("hello".into()),
987            timestamp: 0,
988        });
989        t.apply(&AgentSessionEvent::AgentStart);
990        t.apply(&AgentSessionEvent::MessageStart {
991            message: user.clone(),
992        });
993        let partial = assistant(
994            &[AssistantContent::Text(TextContent::new("Let me look"))],
995            StopReason::Stop,
996        );
997        t.apply(&AgentSessionEvent::MessageStart {
998            message: AgentMessage::Assistant(partial.clone()),
999        });
1000        let with_call = assistant(
1001            &[
1002                AssistantContent::Text(TextContent::new("Let me look")),
1003                call("c1", "read", json!({"path": "a.rs"})),
1004            ],
1005            StopReason::ToolUse,
1006        );
1007        t.apply(&AgentSessionEvent::MessageUpdate {
1008            message: AgentMessage::Assistant(with_call.clone()),
1009            assistant_message_event: serde_json::from_value(
1010                json!({"type":"start","partial": with_call}),
1011            )
1012            .unwrap(),
1013        });
1014        assert!(t.is_streaming());
1015        t.apply(&AgentSessionEvent::MessageEnd {
1016            message: AgentMessage::Assistant(with_call),
1017        });
1018        t.apply(&AgentSessionEvent::ToolExecutionStart {
1019            tool_call_id: "c1".into(),
1020            tool_name: "read".into(),
1021            args: serde_json::from_value(json!({"path": "a.rs"})).unwrap(),
1022            parent_tool_call_id: None,
1023        });
1024        t.apply(&AgentSessionEvent::ToolExecutionEnd {
1025            tool_call_id: "c1".into(),
1026            tool_name: "read".into(),
1027            result: AgentToolResult {
1028                content: vec![UserContent::text("fn main() {}")],
1029                ..AgentToolResult::default()
1030            },
1031            is_error: false,
1032            duration_ms: Some(5),
1033            parent_tool_call_id: None,
1034        });
1035        let kinds: Vec<&RowKind> = t.rows().iter().map(|r| &r.kind).collect();
1036        assert!(matches!(kinds[0], RowKind::User(u) if u.text == "hello"));
1037        assert!(matches!(kinds[1], RowKind::Assistant(a) if a.has_tool_calls && !a.streaming));
1038        let RowKind::Tool(tool) = kinds[2] else {
1039            panic!("tool row")
1040        };
1041        assert_eq!(tool.output.as_ref().unwrap().text(), "fn main() {}");
1042        assert!(!tool.partial);
1043        assert_eq!(t.rows().len(), 3);
1044    }
1045
1046    #[test]
1047    fn aborted_message_marks_pending_tools() {
1048        let mut t = Transcript::new();
1049        let msg = assistant(
1050            &[call("c1", "bash", json!({"command": "ls"}))],
1051            StopReason::Aborted,
1052        );
1053        t.apply(&AgentSessionEvent::MessageStart {
1054            message: AgentMessage::Assistant(msg.clone()),
1055        });
1056        t.apply(&AgentSessionEvent::MessageUpdate {
1057            message: AgentMessage::Assistant(msg.clone()),
1058            assistant_message_event: serde_json::from_value(json!({"type":"start","partial": msg}))
1059                .unwrap(),
1060        });
1061        t.apply(&AgentSessionEvent::MessageEnd {
1062            message: AgentMessage::Assistant(msg),
1063        });
1064        let RowKind::Tool(tool) = &t.rows()[1].kind else {
1065            panic!()
1066        };
1067        let out = tool.output.as_ref().unwrap();
1068        assert!(out.is_error);
1069        assert_eq!(out.text(), "Operation aborted");
1070    }
1071
1072    #[test]
1073    fn bash_rows_stream_and_finish() {
1074        let mut t = Transcript::new();
1075        t.begin_bash(Some("b1".into()), "ls", false);
1076        assert!(t.bash_delta(Some("b1"), "a\n"));
1077        assert!(t.finish_bash(
1078            Some("b1"),
1079            BashOutcome {
1080                output: "a\nb\n".into(),
1081                exit_code: Some(0),
1082                ..BashOutcome::default()
1083            }
1084        ));
1085        let RowKind::Bash(b) = &t.rows()[0].kind else {
1086            panic!()
1087        };
1088        assert!(!b.running);
1089        assert_eq!(b.output, "a\nb\n");
1090    }
1091
1092    #[test]
1093    fn rebuilds_from_entries_with_tool_results() {
1094        let entries: Vec<SessionEntry> = serde_json::from_value(json!([
1095            {"type":"message","id":"a1","parentId":null,"timestamp":"2026-01-01T00:00:00.000Z",
1096             "message":{"role":"user","content":"hi","timestamp":0}},
1097            {"type":"message","id":"a2","parentId":"a1","timestamp":"2026-01-01T00:00:01.000Z",
1098             "message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"ls","arguments":{}}],
1099             "api":"x","provider":"x","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,
1100             "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":0}},
1101            {"type":"message","id":"a3","parentId":"a2","timestamp":"2026-01-01T00:00:02.000Z",
1102             "message":{"role":"toolResult","toolCallId":"t1","toolName":"ls","content":[{"type":"text","text":"x.rs"}],"isError":false,"timestamp":0}},
1103            {"type":"custom","id":"a4","parentId":"a3","timestamp":"2026-01-01T00:00:03.000Z","customType":"probe","data":{"n":1}}
1104        ]))
1105        .unwrap();
1106        let mut t = Transcript::new();
1107        t.reset_from_entries(&entries, Some("a4"));
1108        assert_eq!(t.rows().len(), 4);
1109        let RowKind::Tool(tool) = &t.rows()[2].kind else {
1110            panic!()
1111        };
1112        assert_eq!(tool.output.as_ref().unwrap().text(), "x.rs");
1113        assert!(matches!(&t.rows()[3].kind, RowKind::CustomEntry(c) if c.custom_type == "probe"));
1114        assert_eq!(t.user_texts(), vec!["hi".to_owned()]);
1115    }
1116}