1use 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
19pub type RowId = u64;
21
22#[derive(Clone, Debug, PartialEq)]
24pub struct Row {
25 pub id: RowId,
27 pub rev: u64,
29 pub kind: RowKind,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub enum RowKind {
36 User(UserRow),
38 Skill(SkillRow),
40 Assistant(AssistantRow),
42 Tool(ToolRow),
44 Bash(BashRow),
46 Custom(CustomRow),
48 CustomEntry(CustomEntryRow),
50 BranchSummary(SummaryRow),
52 Compaction(SummaryRow),
54 Notice(NoticeRow),
56}
57
58#[derive(Clone, Debug, PartialEq)]
60pub struct UserRow {
61 pub text: String,
63 pub images: Vec<ImageContent>,
65 pub entry_id: Option<String>,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SkillRow {
72 pub name: String,
74 pub location: String,
76 pub content: String,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum AssistantBlock {
83 Text(String),
85 Thinking(String),
87}
88
89#[derive(Clone, Debug, PartialEq)]
91pub struct AssistantRow {
92 pub blocks: Vec<AssistantBlock>,
94 pub has_tool_calls: bool,
96 pub stop_reason: Option<StopReason>,
98 pub error_message: Option<String>,
100 pub streaming: bool,
102}
103
104#[derive(Clone, Debug, PartialEq)]
106pub struct ToolOutput {
107 pub content: Vec<UserContent>,
109 pub details: Option<JsonValue>,
111 pub is_error: bool,
113}
114
115impl ToolOutput {
116 #[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 #[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#[derive(Clone, Debug, PartialEq)]
152pub struct ToolRow {
153 pub tool_call_id: String,
155 pub name: String,
157 pub args: JsonObject,
159 pub args_complete: bool,
161 pub started: bool,
163 pub output: Option<ToolOutput>,
165 pub partial: bool,
167 pub duration_ms: Option<u64>,
169}
170
171#[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 pub request_id: Option<String>,
180 pub command: String,
182 pub output: String,
184 pub exit_code: Option<i32>,
186 pub cancelled: bool,
188 pub truncated: bool,
190 pub full_output_path: Option<String>,
192 pub exclude_from_context: bool,
194 pub running: bool,
196}
197
198#[derive(Clone, Debug, PartialEq)]
200pub struct CustomRow {
201 pub entry_id: Option<String>,
203 pub custom_type: String,
205 pub content: UserContentField,
207 pub details: Option<JsonValue>,
209}
210
211impl CustomRow {
212 #[must_use]
214 pub fn text(&self) -> String {
215 self.content.text("\n")
216 }
217}
218
219#[derive(Clone, Debug, PartialEq)]
221pub struct CustomEntryRow {
222 pub entry_id: String,
224 pub custom_type: String,
226 pub data: Option<JsonValue>,
228}
229
230#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct SummaryRow {
233 pub summary: String,
235 pub tokens_before: u64,
237}
238
239#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum NoticeLevel {
242 Info,
244 Warning,
246 Error,
248 Document,
250}
251
252#[derive(Clone, Debug, PartialEq, Eq)]
254pub struct NoticeRow {
255 pub level: NoticeLevel,
257 pub text: String,
259}
260
261#[derive(Clone, Debug, Default, PartialEq, Eq)]
263pub struct BashOutcome {
264 pub output: String,
266 pub exit_code: Option<i32>,
268 pub cancelled: bool,
270 pub truncated: bool,
272 pub full_output_path: Option<String>,
274}
275
276#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct Applied {
279 pub changed: bool,
281 pub reload: bool,
283}
284
285#[derive(Clone, Debug, Default)]
287pub struct Transcript {
288 rows: Vec<Row>,
289 next_id: RowId,
290 next_rev: u64,
291 pending_tools: HashMap<String, RowId>,
293 streaming: Option<RowId>,
295}
296
297#[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
394fn 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 #[must_use]
409 pub fn new() -> Self {
410 Self::default()
411 }
412
413 #[must_use]
415 pub fn rows(&self) -> &[Row] {
416 &self.rows
417 }
418
419 #[must_use]
421 pub const fn is_streaming(&self) -> bool {
422 self.streaming.is_some()
423 }
424
425 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 #[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}