1use monoloop_contracts::{
45 CanonicalUnit, InterpreterOutputEvent, SourceTimeObservation, TextChannel, ToolRequestState,
46 UnitState,
47};
48use std::collections::HashMap;
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum ProjectionStrategy {
53 ChronologicalChat,
55 StructuralOrdinalZip,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ProjectionConfidence {
62 EmitOrder,
64 DialectSourceTime,
67 DialectSourceStep,
70 StructuralReorder,
72}
73
74#[derive(Clone, Debug)]
76pub struct ProjectChatOptions {
77 pub allow_structural_zip: bool,
80 pub order_by_source_time: bool,
85 pub annotate_source_time: bool,
88}
89
90impl Default for ProjectChatOptions {
91 fn default() -> Self {
92 Self {
93 allow_structural_zip: true,
94 order_by_source_time: true,
97 annotate_source_time: true,
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum ChatRole {
105 Agent,
107 Thinking,
109 Tool,
111 Status,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct ProjectedTool {
118 pub action_id: String,
120 pub title: String,
122 pub verb: String,
124 pub args: Option<String>,
126 pub terminal: Option<String>,
128 pub state: String,
130 pub source_time: Option<SourceTimeObservation>,
132 pub source_step: Option<u64>,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct ChatLine {
139 pub role: ChatRole,
141 pub text: String,
143 pub tool: Option<ProjectedTool>,
145 pub reordered: bool,
147 pub source_time: Option<SourceTimeObservation>,
149 pub source_step: Option<u64>,
151}
152
153#[derive(Clone, Debug)]
155pub struct ChatProjection {
156 pub strategy: ProjectionStrategy,
158 pub confidence: ProjectionConfidence,
160 pub strategy_reason: String,
162 pub lines: Vec<ChatLine>,
164 pub plain_text: String,
166 pub html: String,
168 pub disclaimer: &'static str,
170}
171
172const DISCLAIMER: &str = "Chat projection is a human-readable report, not ground truth. \
173When dialect source times or stream steps are present, chat lines are ordered by \
174those observational keys so readers see production order rather than emit-order \
175jumble. Structural ordinal zip (when shown) only reorders tools against later \
176numbered list steps when counts match — it does not invent speech. Use the \
177event-order interleaved stream and canonical timeline for exact Interpreter \
178emit order.";
179
180pub fn project_chat(events: &[InterpreterOutputEvent]) -> ChatProjection {
182 project_chat_with(events, &ProjectChatOptions::default())
183}
184
185pub fn project_chat_with(
187 events: &[InterpreterOutputEvent],
188 opts: &ProjectChatOptions,
189) -> ChatProjection {
190 let mut extracted = extract(events);
191 let source_order = if opts.order_by_source_time {
192 apply_source_order(&mut extracted.chrono)
193 } else {
194 SourceOrderApplied::None
195 };
196 let (strategy, reason) = choose_strategy(&extracted, opts);
197 let mut reason = reason;
198 match source_order {
199 SourceOrderApplied::ByTime => {
200 reason.push_str(
201 "; human chat ordered by dialect source_time.first_ms (then source_step, then emit)",
202 );
203 }
204 SourceOrderApplied::ByStep => {
205 reason.push_str(
206 "; human chat ordered by dialect source_step (emit order preserved when steps absent)",
207 );
208 }
209 SourceOrderApplied::None => {}
210 }
211 let confidence = match strategy {
212 ProjectionStrategy::ChronologicalChat => match source_order {
213 SourceOrderApplied::ByTime => ProjectionConfidence::DialectSourceTime,
214 SourceOrderApplied::ByStep => ProjectionConfidence::DialectSourceStep,
215 SourceOrderApplied::None => ProjectionConfidence::EmitOrder,
216 },
217 ProjectionStrategy::StructuralOrdinalZip => ProjectionConfidence::StructuralReorder,
218 };
219 let lines = match strategy {
220 ProjectionStrategy::StructuralOrdinalZip => assemble_structural_zip(&extracted),
221 ProjectionStrategy::ChronologicalChat => assemble_chronological(&extracted),
222 };
223 let lines = merge_consecutive_same_role(&lines);
224 let plain_text = render_plain(
225 &lines,
226 strategy,
227 confidence,
228 &reason,
229 opts.annotate_source_time,
230 );
231 let html = render_html(
232 &lines,
233 strategy,
234 confidence,
235 &reason,
236 opts.annotate_source_time,
237 );
238 ChatProjection {
239 strategy,
240 confidence,
241 strategy_reason: reason,
242 lines,
243 plain_text,
244 html,
245 disclaimer: DISCLAIMER,
246 }
247}
248
249struct Extracted {
252 chrono: Vec<ChronoBlock>,
254 tools: Vec<ProjectedTool>,
256 public: Vec<String>,
258 reasoning: Vec<String>,
260 status: Vec<String>,
262 tools_before_public: bool,
264}
265
266enum ChronoBlock {
267 Text {
268 channel: TextChannel,
269 content: String,
270 source_time: Option<SourceTimeObservation>,
271 source_step: Option<u64>,
272 emit_index: usize,
273 },
274 Tool {
275 tool: ProjectedTool,
276 emit_index: usize,
277 },
278}
279
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282enum SourceOrderApplied {
283 None,
284 ByTime,
285 ByStep,
286}
287
288fn extract(events: &[InterpreterOutputEvent]) -> Extracted {
289 let mut chrono: Vec<ChronoBlock> = Vec::new();
290 let mut tools: Vec<ProjectedTool> = Vec::new();
291 let mut tool_index: HashMap<String, usize> = HashMap::new();
292 let mut public: Vec<String> = Vec::new();
293 let mut reasoning: Vec<String> = Vec::new();
294 let mut status: Vec<String> = Vec::new();
295 let mut saw_public = false;
296 let mut tool_after_public = false;
297 let mut emit_index = 0usize;
298
299 for ev in events {
300 let InterpreterOutputEvent::Unit(unit_ev) = ev else {
301 continue;
302 };
303 let snap = unit_ev.snapshot();
304 match &snap.unit {
305 CanonicalUnit::Text(t) => match t.channel {
306 TextChannel::PublicResponse => {
307 saw_public = true;
308 public.push(t.content.clone());
309 chrono.push(ChronoBlock::Text {
310 channel: t.channel,
311 content: t.content.clone(),
312 source_time: snap.source_time,
313 source_step: snap.source_step,
314 emit_index,
315 });
316 emit_index += 1;
317 }
318 TextChannel::PublicReasoningSummary => {
319 reasoning.push(t.content.clone());
320 chrono.push(ChronoBlock::Text {
321 channel: t.channel,
322 content: t.content.clone(),
323 source_time: snap.source_time,
324 source_step: snap.source_step,
325 emit_index,
326 });
327 emit_index += 1;
328 }
329 TextChannel::StatusNarration => {
330 status.push(t.content.clone());
331 chrono.push(ChronoBlock::Text {
332 channel: t.channel,
333 content: t.content.clone(),
334 source_time: snap.source_time,
335 source_step: snap.source_step,
336 emit_index,
337 });
338 emit_index += 1;
339 }
340 TextChannel::QuotedExternalContent => {}
341 },
342 CanonicalUnit::Tool(t) => {
343 if saw_public {
344 tool_after_public = true;
345 }
346 let projected =
347 projected_tool(t, snap.unit_state, snap.source_time, snap.source_step);
348 let id = projected.action_id.clone();
349 if let Some(&idx) = tool_index.get(&id) {
350 tools[idx] = projected.clone();
351 if let Some(ChronoBlock::Tool { tool: slot, .. }) =
352 chrono.iter_mut().find(|b| match b {
353 ChronoBlock::Tool { tool: p, .. } => p.action_id == id,
354 _ => false,
355 })
356 {
357 *slot = projected;
358 }
359 } else {
360 tool_index.insert(id, tools.len());
361 tools.push(projected.clone());
362 chrono.push(ChronoBlock::Tool {
363 tool: projected,
364 emit_index,
365 });
366 emit_index += 1;
367 }
368 }
369 _ => {}
370 }
371 }
372
373 let tools_before_public = !tools.is_empty() && !public.is_empty() && !tool_after_public;
374
375 Extracted {
376 chrono,
377 tools,
378 public,
379 reasoning,
380 status,
381 tools_before_public,
382 }
383}
384
385fn projected_tool(
386 t: &monoloop_contracts::ToolActionEvent,
387 unit_state: UnitState,
388 source_time: Option<SourceTimeObservation>,
389 source_step: Option<u64>,
390) -> ProjectedTool {
391 let title = t.tool_name.clone().unwrap_or_else(|| "tool".into());
392 let verb = display_verb(&title, t.request_payload.as_deref());
393 let state = {
394 let base = match t.request_state {
395 ToolRequestState::Ready => "ready",
396 ToolRequestState::Assembling => "waiting",
397 ToolRequestState::Incomplete => "incomplete",
398 ToolRequestState::Malformed => "malformed",
399 };
400 if unit_state == UnitState::Complete {
401 format!("{base}/complete")
402 } else {
403 base.to_string()
404 }
405 };
406 ProjectedTool {
407 action_id: t.tool_action_id.as_str().to_string(),
408 title,
409 verb,
410 args: t.request_payload.clone(),
411 terminal: t.terminal_outcome.map(|o| format!("{o:?}")),
412 state,
413 source_time,
414 source_step,
415 }
416}
417
418fn apply_source_order(chrono: &mut [ChronoBlock]) -> SourceOrderApplied {
423 if chrono.is_empty() {
424 return SourceOrderApplied::None;
425 }
426 let any_time = chrono.iter().any(|b| block_source_time(b).is_some());
427 let any_step = chrono.iter().any(|b| block_source_step(b).is_some());
428 if !any_time && !any_step {
429 return SourceOrderApplied::None;
430 }
431 let before: Vec<usize> = chrono.iter().map(block_emit_index).collect();
432 chrono.sort_by_key(|b| {
433 let t = block_source_time(b).map(|s| s.first_ms).unwrap_or(u64::MAX);
434 let step = block_source_step(b).unwrap_or(u64::MAX);
435 (t, step, block_emit_index(b))
436 });
437 let after: Vec<usize> = chrono.iter().map(block_emit_index).collect();
438 if before == after {
439 return SourceOrderApplied::None;
440 }
441 if any_time {
442 SourceOrderApplied::ByTime
443 } else {
444 SourceOrderApplied::ByStep
445 }
446}
447
448fn block_source_time(b: &ChronoBlock) -> Option<SourceTimeObservation> {
449 match b {
450 ChronoBlock::Text { source_time, .. } => *source_time,
451 ChronoBlock::Tool { tool, .. } => tool.source_time,
452 }
453}
454
455fn block_source_step(b: &ChronoBlock) -> Option<u64> {
456 match b {
457 ChronoBlock::Text { source_step, .. } => *source_step,
458 ChronoBlock::Tool { tool, .. } => tool.source_step,
459 }
460}
461
462fn block_emit_index(b: &ChronoBlock) -> usize {
463 match b {
464 ChronoBlock::Text { emit_index, .. } | ChronoBlock::Tool { emit_index, .. } => *emit_index,
465 }
466}
467
468fn format_source_meta(st: Option<SourceTimeObservation>, step: Option<u64>) -> Option<String> {
469 match (st, step) {
470 (Some(t), Some(s)) => {
471 if t.first_ms == t.last_ms {
472 Some(format!("t={} s={s}", t.first_ms))
473 } else {
474 Some(format!("t={}..{} s={s}", t.first_ms, t.last_ms))
475 }
476 }
477 (Some(t), None) => {
478 if t.first_ms == t.last_ms {
479 Some(format!("t={}", t.first_ms))
480 } else {
481 Some(format!("t={}..{}", t.first_ms, t.last_ms))
482 }
483 }
484 (None, Some(s)) => Some(format!("s={s}")),
485 (None, None) => None,
486 }
487}
488
489fn display_verb(title: &str, args: Option<&str>) -> String {
491 let lower = title.to_ascii_lowercase();
492 if lower.starts_with("write") || lower.contains("write `") {
493 return "Write".into();
494 }
495 if lower.starts_with("read") || lower.contains("read `") || lower.contains("read_file") {
496 return "Read".into();
497 }
498 if lower.starts_with("execute") || lower.starts_with("run") || lower.contains("terminal") {
499 return "Execute".into();
500 }
501 if lower.starts_with("delete") || lower.starts_with("remove") {
502 return "Delete".into();
503 }
504 if let Some(a) = args {
505 if a.contains("\"command\"") {
506 return "Execute".into();
507 }
508 if a.contains("file_path") && a.contains("content") {
509 return "Write".into();
510 }
511 if a.contains("target_file") {
512 return "Read".into();
513 }
514 }
515 title
516 .split_whitespace()
517 .next()
518 .unwrap_or("Tool")
519 .to_string()
520}
521
522fn choose_strategy(ex: &Extracted, opts: &ProjectChatOptions) -> (ProjectionStrategy, String) {
525 if !opts.allow_structural_zip {
526 return (
527 ProjectionStrategy::ChronologicalChat,
528 "structural zip disabled by options".into(),
529 );
530 }
531
532 if !ex.tools_before_public {
533 return (
534 ProjectionStrategy::ChronologicalChat,
535 "emit-order chat (tools not strictly before public text, or no tools/text)".into(),
536 );
537 }
538
539 let list_steps = count_list_steps(&ex.public);
540 let n_tools = ex.tools.len();
541
542 if list_steps == 0 {
543 return (
544 ProjectionStrategy::ChronologicalChat,
545 "tools-first dump without numbered list steps — chronological (no safe zip)".into(),
546 );
547 }
548
549 if list_steps != n_tools {
550 return (
551 ProjectionStrategy::ChronologicalChat,
552 format!(
553 "tools-first but list steps ({list_steps}) ≠ tools ({n_tools}) — \
554 chronological (refuse mis-pairing)"
555 ),
556 );
557 }
558
559 (
560 ProjectionStrategy::StructuralOrdinalZip,
561 format!(
562 "tools-first + {n_tools} numbered steps matching tool count — \
563 ordinal zip only (no keyword pairing)"
564 ),
565 )
566}
567
568fn count_list_steps(public: &[String]) -> usize {
569 public
570 .iter()
571 .filter(|s| looks_like_list_item(s.trim()))
572 .count()
573}
574
575fn looks_like_list_item(s: &str) -> bool {
578 let b = s.as_bytes();
579 let mut i = 0;
580 while i < b.len() && b[i].is_ascii_digit() {
581 i += 1;
582 }
583 i > 0 && i < b.len() && b[i] == b'.'
585}
586
587fn split_public_by_list_items(public: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
588 let mut preamble = Vec::new();
589 let mut steps = Vec::new();
590 let mut epilogue = Vec::new();
591 let mut seen_step = false;
592
593 for s in public {
594 if looks_like_list_item(s.trim()) {
595 seen_step = true;
596 steps.push(s.clone());
597 } else if !seen_step {
598 preamble.push(s.clone());
599 } else {
600 epilogue.push(s.clone());
601 }
602 }
603 (preamble, steps, epilogue)
604}
605
606fn assemble_structural_zip(ex: &Extracted) -> Vec<ChatLine> {
607 let mut lines = Vec::new();
608 let (preamble, steps, epilogue) = split_public_by_list_items(&ex.public);
609
610 for s in &ex.reasoning {
613 lines.push(line(ChatRole::Thinking, s.clone(), None, true, None, None));
614 }
615 for s in &ex.status {
616 lines.push(line(ChatRole::Status, s.clone(), None, true, None, None));
617 }
618 for s in &preamble {
619 lines.push(line(ChatRole::Agent, s.clone(), None, true, None, None));
620 }
621
622 for (tool, step) in ex.tools.iter().zip(steps.iter()) {
624 lines.push(line(
625 ChatRole::Tool,
626 String::new(),
627 Some(tool.clone()),
628 true,
629 tool.source_time,
630 tool.source_step,
631 ));
632 lines.push(line(ChatRole::Agent, step.clone(), None, true, None, None));
633 }
634
635 for s in &epilogue {
636 lines.push(line(ChatRole::Agent, s.clone(), None, true, None, None));
637 }
638 lines
639}
640
641fn assemble_chronological(ex: &Extracted) -> Vec<ChatLine> {
642 let mut lines = Vec::new();
643 let mut pending_role: Option<ChatRole> = None;
644 let mut pending_text: Vec<String> = Vec::new();
645 let mut pending_time: Option<SourceTimeObservation> = None;
646 let mut pending_step: Option<u64> = None;
647
648 let flush = |role: &mut Option<ChatRole>,
649 buf: &mut Vec<String>,
650 time: &mut Option<SourceTimeObservation>,
651 step: &mut Option<u64>,
652 lines: &mut Vec<ChatLine>| {
653 if let Some(r) = role.take() {
654 if !buf.is_empty() {
655 lines.push(line(
656 r,
657 join_soft(buf),
658 None,
659 false,
660 time.take(),
661 step.take(),
662 ));
663 buf.clear();
664 }
665 }
666 };
667
668 for b in &ex.chrono {
669 match b {
670 ChronoBlock::Text {
671 channel,
672 content,
673 source_time,
674 source_step,
675 ..
676 } => {
677 let role = match channel {
678 TextChannel::PublicResponse => ChatRole::Agent,
679 TextChannel::PublicReasoningSummary => ChatRole::Thinking,
680 TextChannel::StatusNarration => ChatRole::Status,
681 TextChannel::QuotedExternalContent => continue,
682 };
683 if pending_role != Some(role) {
684 flush(
685 &mut pending_role,
686 &mut pending_text,
687 &mut pending_time,
688 &mut pending_step,
689 &mut lines,
690 );
691 pending_role = Some(role);
692 }
693 pending_text.push(content.clone());
694 pending_time = match (pending_time, *source_time) {
695 (Some(a), Some(b)) => Some(a.merge(b)),
696 (Some(a), None) => Some(a),
697 (None, Some(b)) => Some(b),
698 (None, None) => None,
699 };
700 pending_step = match (pending_step, *source_step) {
701 (Some(a), Some(b)) => Some(a.min(b)),
702 (Some(a), None) => Some(a),
703 (None, Some(b)) => Some(b),
704 (None, None) => None,
705 };
706 }
707 ChronoBlock::Tool { tool: t, .. } => {
708 flush(
709 &mut pending_role,
710 &mut pending_text,
711 &mut pending_time,
712 &mut pending_step,
713 &mut lines,
714 );
715 lines.push(line(
716 ChatRole::Tool,
717 String::new(),
718 Some(t.clone()),
719 false,
720 t.source_time,
721 t.source_step,
722 ));
723 }
724 }
725 }
726 flush(
727 &mut pending_role,
728 &mut pending_text,
729 &mut pending_time,
730 &mut pending_step,
731 &mut lines,
732 );
733 lines
734}
735
736fn line(
737 role: ChatRole,
738 text: String,
739 tool: Option<ProjectedTool>,
740 reordered: bool,
741 source_time: Option<SourceTimeObservation>,
742 source_step: Option<u64>,
743) -> ChatLine {
744 ChatLine {
745 role,
746 text,
747 tool,
748 reordered,
749 source_time,
750 source_step,
751 }
752}
753
754fn join_soft(parts: &[String]) -> String {
755 let mut out = String::new();
756 for s in parts {
757 let t = s.trim();
758 if t.is_empty() {
759 continue;
760 }
761 if out.is_empty() {
762 out.push_str(t);
763 } else if looks_like_list_item(t) {
764 out.push('\n');
765 out.push_str(t);
766 } else {
767 out.push_str("\n\n");
768 out.push_str(t);
769 }
770 }
771 out
772}
773
774fn merge_consecutive_same_role(lines: &[ChatLine]) -> Vec<ChatLine> {
776 let mut out: Vec<ChatLine> = Vec::new();
777 for line in lines {
778 if line.role != ChatRole::Tool {
779 if let Some(prev) = out.last_mut() {
780 if prev.role == line.role && prev.tool.is_none() && line.tool.is_none() {
781 if !prev.text.is_empty() && !line.text.is_empty() {
782 if looks_like_list_item(line.text.trim()) {
783 prev.text.push('\n');
784 } else {
785 prev.text.push_str("\n\n");
786 }
787 }
788 prev.text.push_str(&line.text);
789 prev.reordered = prev.reordered || line.reordered;
790 prev.source_time = match (prev.source_time, line.source_time) {
791 (Some(a), Some(b)) => Some(a.merge(b)),
792 (Some(a), None) => Some(a),
793 (None, Some(b)) => Some(b),
794 (None, None) => None,
795 };
796 prev.source_step = match (prev.source_step, line.source_step) {
797 (Some(a), Some(b)) => Some(a.min(b)),
798 (Some(a), None) => Some(a),
799 (None, Some(b)) => Some(b),
800 (None, None) => None,
801 };
802 continue;
803 }
804 }
805 }
806 out.push(line.clone());
807 }
808 out
809}
810
811fn render_plain(
814 lines: &[ChatLine],
815 strategy: ProjectionStrategy,
816 confidence: ProjectionConfidence,
817 reason: &str,
818 annotate_source_time: bool,
819) -> String {
820 let mut out = String::new();
821 out.push_str(&format!(
822 "=== CHAT PROJECTION ({strategy:?} / {confidence:?}) — not ground truth ===\n"
823 ));
824 out.push_str(DISCLAIMER);
825 out.push_str("\n");
826 out.push_str(&format!("reason: {reason}\n\n"));
827 for line in lines {
828 let t_note = if annotate_source_time {
829 format_source_meta(line.source_time, line.source_step)
830 .map(|s| format!(" [{s}]"))
831 .unwrap_or_default()
832 } else {
833 String::new()
834 };
835 match (&line.role, &line.tool) {
836 (ChatRole::Agent, _) => {
837 out.push_str(&format!("AGENT{t_note}:\n"));
838 out.push_str(&line.text);
839 out.push_str("\n\n");
840 }
841 (ChatRole::Thinking, _) => {
842 out.push_str(&format!("THINKING{t_note}:\n… "));
843 out.push_str(&line.text);
844 out.push_str("\n\n");
845 }
846 (ChatRole::Status, _) => {
847 out.push_str(&format!("STATUS{t_note}:\n"));
848 out.push_str(&line.text);
849 out.push_str("\n\n");
850 }
851 (ChatRole::Tool, Some(t)) => {
852 out.push_str(&format!("TOOL{t_note}: {} — {}\n", t.verb, t.title));
853 if let Some(term) = &t.terminal {
854 out.push_str(&format!(" → {term}\n"));
855 }
856 out.push('\n');
857 }
858 (ChatRole::Tool, None) => {}
859 }
860 }
861 out
862}
863
864fn render_html(
865 lines: &[ChatLine],
866 strategy: ProjectionStrategy,
867 confidence: ProjectionConfidence,
868 reason: &str,
869 annotate_source_time: bool,
870) -> String {
871 let conf_class = match confidence {
872 ProjectionConfidence::EmitOrder => "conf-emit",
873 ProjectionConfidence::DialectSourceTime => "conf-source-time",
874 ProjectionConfidence::DialectSourceStep => "conf-source-step",
875 ProjectionConfidence::StructuralReorder => "conf-structural",
876 };
877 let mut out = String::new();
878 out.push_str(&format!(
879 "<div class=\"chat-projection {conf_class}\" data-strategy=\"{strategy:?}\" \
880 data-confidence=\"{confidence:?}\">\n"
881 ));
882 out.push_str("<div class=\"chat-disclaimer\">");
883 out.push_str(&escape(DISCLAIMER));
884 out.push_str("</div>\n");
885 out.push_str(&format!(
886 "<p class=\"chat-strategy\">Strategy: <code>{strategy:?}</code> · \
887 Confidence: <code>{confidence:?}</code><br/>\
888 <span class=\"chat-reason\">{}</span></p>\n",
889 escape(reason)
890 ));
891 out.push_str("<div class=\"chat-flow\">\n");
892
893 for line in lines {
894 let t_html = if annotate_source_time {
895 format_source_meta(line.source_time, line.source_step)
896 .map(|s| format!(" <span class=\"chat-source-time\">{}</span>", escape(&s)))
897 .unwrap_or_default()
898 } else {
899 String::new()
900 };
901 match (&line.role, &line.tool) {
902 (ChatRole::Agent, _) => {
903 out.push_str(&agent_bubble(&line.text, line.reordered, &t_html));
904 }
905 (ChatRole::Thinking, _) => {
906 out.push_str("<div class=\"chat-line thinking");
907 if line.reordered {
908 out.push_str(" reordered");
909 }
910 out.push_str("\"><div class=\"chat-role\">Thinking");
911 out.push_str(&t_html);
912 out.push_str("</div>");
913 out.push_str("<div class=\"chat-body thinking-body\">… ");
914 out.push_str(&md_html(&line.text));
915 out.push_str("</div></div>\n");
916 }
917 (ChatRole::Status, _) => {
918 out.push_str("<div class=\"chat-line status");
919 if line.reordered {
920 out.push_str(" reordered");
921 }
922 out.push_str("\"><div class=\"chat-role\">Status");
923 out.push_str(&t_html);
924 out.push_str("</div>");
925 out.push_str("<div class=\"chat-body\">");
926 out.push_str(&md_html(&line.text));
927 out.push_str("</div></div>\n");
928 }
929 (ChatRole::Tool, Some(t)) => {
930 out.push_str("<div class=\"chat-line tool");
931 if line.reordered {
932 out.push_str(" reordered");
933 }
934 out.push_str("\">");
935 out.push_str("<div class=\"chat-role\">Tool");
936 out.push_str(&t_html);
937 out.push_str("</div>");
938 out.push_str("<div class=\"chat-tool-card\">");
939 out.push_str(&format!(
940 "<span class=\"chat-tool-verb\">{}</span> \
941 <span class=\"chat-tool-title\">{}</span>",
942 escape(&t.verb),
943 escape(&t.title)
944 ));
945 if let Some(term) = &t.terminal {
946 out.push_str(&format!(
947 " <span class=\"chat-tool-term\">→ {}</span>",
948 escape(term)
949 ));
950 }
951 out.push_str(&format!(
952 " <span class=\"chat-tool-state\">{}</span>",
953 escape(&t.state)
954 ));
955 if let Some(a) = &t.args {
956 let clipped = if a.chars().count() > 400 {
957 format!("{}…", a.chars().take(400).collect::<String>())
958 } else {
959 a.clone()
960 };
961 out.push_str(&format!(
962 "<pre class=\"chat-tool-args\">{}</pre>",
963 escape(&clipped)
964 ));
965 }
966 out.push_str("</div></div>\n");
967 }
968 (ChatRole::Tool, None) => {}
969 }
970 }
971
972 out.push_str("</div></div>\n");
973 out
974}
975
976fn agent_bubble(text: &str, reordered: bool, time_html: &str) -> String {
977 let mut out = String::new();
978 out.push_str("<div class=\"chat-line agent");
979 if reordered {
980 out.push_str(" reordered");
981 }
982 out.push_str("\"><div class=\"chat-role\">Agent");
983 out.push_str(time_html);
984 out.push_str("</div>");
985 out.push_str("<div class=\"chat-body\">");
986 out.push_str(&md_html(text));
987 out.push_str("</div></div>\n");
988 out
989}
990
991fn md_html(md: &str) -> String {
992 use pulldown_cmark::{html, Options, Parser};
993 let mut options = Options::empty();
994 options.insert(Options::ENABLE_STRIKETHROUGH);
995 let parser = Parser::new_ext(md, options);
996 let mut out = String::new();
997 html::push_html(&mut out, parser);
998 out
999}
1000
1001fn escape(s: &str) -> String {
1002 let mut out = String::with_capacity(s.len());
1003 for c in s.chars() {
1004 match c {
1005 '&' => out.push_str("&"),
1006 '<' => out.push_str("<"),
1007 '>' => out.push_str(">"),
1008 '"' => out.push_str("""),
1009 '\'' => out.push_str("'"),
1010 c => out.push(c),
1011 }
1012 }
1013 out
1014}
1015
1016#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use monoloop_contracts::{
1022 CanonicalUnit, CanonicalUnitEvent, CanonicalUnitSnapshot, ConnectionId, FlowId,
1023 InterpretationId, LaneId, TextSentence, ToolActionEvent, ToolActionId, ToolExecutionState,
1024 ToolRequestState, ToolResultState, ToolTerminalOutcome, UnitId, UnitState,
1025 };
1026
1027 fn text_ev(content: &str, n: u64) -> InterpreterOutputEvent {
1028 text_ev_meta(content, n, None, None)
1029 }
1030
1031 fn text_ev_at(
1032 content: &str,
1033 n: u64,
1034 source_time: Option<SourceTimeObservation>,
1035 ) -> InterpreterOutputEvent {
1036 text_ev_meta(content, n, source_time, None)
1037 }
1038
1039 fn text_ev_step(content: &str, n: u64, step: u64) -> InterpreterOutputEvent {
1040 text_ev_meta(content, n, None, Some(step))
1041 }
1042
1043 fn text_ev_meta(
1044 content: &str,
1045 n: u64,
1046 source_time: Option<SourceTimeObservation>,
1047 source_step: Option<u64>,
1048 ) -> InterpreterOutputEvent {
1049 InterpreterOutputEvent::Unit(Box::new(CanonicalUnitEvent::Created(
1050 CanonicalUnitSnapshot {
1051 unit_id: UnitId::new(format!("s{n}")),
1052 unit_generation: 1,
1053 unit_state: UnitState::Complete,
1054 interpretation_id: InterpretationId::new("i1"),
1055 connection_id: ConnectionId::new("c1"),
1056 external_session_id: None,
1057 flow_id: FlowId::main(),
1058 lane_id: LaneId::response(),
1059 lane_ordinal: n,
1060 causal_parent_id: None,
1061 source_time,
1062 source_step,
1063 unit: CanonicalUnit::Text(TextSentence {
1064 sentence_id: UnitId::new(format!("s{n}")),
1065 channel: TextChannel::PublicResponse,
1066 paragraph_id: None,
1067 sentence_ordinal: n,
1068 content: content.into(),
1069 }),
1070 },
1071 )))
1072 }
1073
1074 fn tool_ev(id: &str, name: &str, n: u64) -> InterpreterOutputEvent {
1075 tool_ev_meta(id, name, n, None, None)
1076 }
1077
1078 fn tool_ev_at(
1079 id: &str,
1080 name: &str,
1081 n: u64,
1082 source_time: Option<SourceTimeObservation>,
1083 ) -> InterpreterOutputEvent {
1084 tool_ev_meta(id, name, n, source_time, None)
1085 }
1086
1087 fn tool_ev_step(id: &str, name: &str, n: u64, step: u64) -> InterpreterOutputEvent {
1088 tool_ev_meta(id, name, n, None, Some(step))
1089 }
1090
1091 fn tool_ev_meta(
1092 id: &str,
1093 name: &str,
1094 n: u64,
1095 source_time: Option<SourceTimeObservation>,
1096 source_step: Option<u64>,
1097 ) -> InterpreterOutputEvent {
1098 InterpreterOutputEvent::Unit(Box::new(CanonicalUnitEvent::Created(
1099 CanonicalUnitSnapshot {
1100 unit_id: UnitId::new(format!("t{n}")),
1101 unit_generation: 1,
1102 unit_state: UnitState::Complete,
1103 interpretation_id: InterpretationId::new("i1"),
1104 connection_id: ConnectionId::new("c1"),
1105 external_session_id: None,
1106 flow_id: FlowId::main(),
1107 lane_id: LaneId::response(),
1108 lane_ordinal: n,
1109 causal_parent_id: None,
1110 source_time,
1111 source_step,
1112 unit: CanonicalUnit::Tool(ToolActionEvent {
1113 tool_action_id: ToolActionId::new(id),
1114 tool_name: Some(name.into()),
1115 request_state: ToolRequestState::Ready,
1116 execution_state: ToolExecutionState::Terminal,
1117 result_state: ToolResultState::Complete,
1118 request_payload: Some("{}".into()),
1119 result_payload: None,
1120 terminal_outcome: Some(ToolTerminalOutcome::Success),
1121 waiting_for: None,
1122 }),
1123 },
1124 )))
1125 }
1126
1127 fn st(ms: u64) -> SourceTimeObservation {
1128 SourceTimeObservation::point(ms)
1129 }
1130
1131 #[test]
1132 fn tools_first_equal_list_steps_structural_zip() {
1133 let events = vec![
1134 tool_ev("a1", "Write `file.txt`", 1),
1135 tool_ev("a2", "Read `file.txt`", 2),
1136 tool_ev("a3", "Execute `rm file.txt`", 3),
1137 text_ev("I'll run the steps.", 4),
1138 text_ev("1. **CREATE** — Wrote the file.", 5),
1139 text_ev("2. **READ** — File contained x.", 6),
1140 text_ev("3. **DELETE** — Removed the file.", 7),
1141 text_ev("No other files were touched.", 8),
1142 ];
1143 let p = project_chat(&events);
1144 assert_eq!(p.strategy, ProjectionStrategy::StructuralOrdinalZip);
1145 assert_eq!(p.confidence, ProjectionConfidence::StructuralReorder);
1146 assert!(p.strategy_reason.contains("ordinal zip"));
1147 assert_eq!(p.lines[0].role, ChatRole::Agent);
1149 assert!(p.lines[0].text.contains("I'll run"));
1150 let roles: Vec<ChatRole> = p.lines.iter().map(|l| l.role).collect();
1151 assert!(
1152 roles
1153 .windows(2)
1154 .any(|w| w == [ChatRole::Tool, ChatRole::Agent]),
1155 "expected tool then step: {roles:?}"
1156 );
1157 let plain = &p.plain_text;
1159 assert!(plain.find("Write").unwrap() < plain.find("CREATE").unwrap());
1160 assert!(plain.contains("No other files were touched"));
1161 }
1162
1163 #[test]
1164 fn tools_first_mismatched_counts_stays_chronological() {
1165 let events = vec![
1166 tool_ev("a1", "Write `file.txt`", 1),
1167 tool_ev("a2", "Read `file.txt`", 2),
1168 text_ev("Only one step listed:", 3),
1169 text_ev("1. Did something.", 4),
1170 ];
1171 let p = project_chat(&events);
1172 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1173 assert_eq!(p.confidence, ProjectionConfidence::EmitOrder);
1174 assert!(p.strategy_reason.contains("≠") || p.strategy_reason.contains("refuse"));
1175 let roles: Vec<_> = p.lines.iter().map(|l| l.role).collect();
1177 assert_eq!(roles, vec![ChatRole::Tool, ChatRole::Tool, ChatRole::Agent]);
1178 assert!(p.lines.iter().all(|l| !l.reordered));
1179 }
1180
1181 #[test]
1182 fn tools_first_free_prose_stays_chronological() {
1183 let events = vec![
1184 tool_ev("a1", "search", 1),
1185 tool_ev("a2", "Write `x`", 2),
1186 text_ev("I searched and then wrote the file. Looks good.", 3),
1187 ];
1188 let p = project_chat(&events);
1189 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1190 assert!(p.strategy_reason.contains("without numbered list"));
1191 }
1192
1193 #[test]
1194 fn chronological_when_text_between_tools() {
1195 let events = vec![
1196 text_ev("Let me start.", 1),
1197 tool_ev("a1", "Read `a.txt`", 2),
1198 text_ev("Looks good, writing next.", 3),
1199 tool_ev("a2", "Write `a.txt`", 4),
1200 text_ev("Done.", 5),
1201 ];
1202 let p = project_chat(&events);
1203 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1204 assert_eq!(p.confidence, ProjectionConfidence::EmitOrder);
1205 let roles: Vec<_> = p.lines.iter().map(|l| l.role).collect();
1206 assert_eq!(
1207 roles,
1208 vec![
1209 ChatRole::Agent,
1210 ChatRole::Tool,
1211 ChatRole::Agent,
1212 ChatRole::Tool,
1213 ChatRole::Agent,
1214 ]
1215 );
1216 assert!(p.lines.iter().all(|l| !l.reordered));
1217 }
1218
1219 #[test]
1220 fn force_chronological_option() {
1221 let events = vec![tool_ev("a1", "Write `f`", 1), text_ev("1. Wrote it.", 2)];
1222 let p = project_chat_with(
1223 &events,
1224 &ProjectChatOptions {
1225 allow_structural_zip: false,
1226 ..Default::default()
1227 },
1228 );
1229 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1230 assert!(p.strategy_reason.contains("disabled"));
1231 }
1232
1233 #[test]
1236 fn source_time_puts_earlier_speech_before_later_tools() {
1237 let events = vec![
1238 tool_ev_at("call-1", "Write `f`", 1, Some(st(2000))),
1240 tool_ev_at("call-2", "Read `f`", 2, Some(st(3000))),
1241 text_ev_at(
1242 "I'll run the CRUD steps, starting by creating it.",
1243 3,
1244 Some(SourceTimeObservation {
1245 first_ms: 1000,
1246 last_ms: 1500,
1247 }),
1248 ),
1249 text_ev_at("1. **CREATE** — Wrote the file.", 4, Some(st(4000))),
1250 ];
1251 let p = project_chat(&events);
1252 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1253 assert_eq!(p.confidence, ProjectionConfidence::DialectSourceTime);
1254 assert!(
1255 p.strategy_reason.contains("source_time"),
1256 "{}",
1257 p.strategy_reason
1258 );
1259 let roles: Vec<_> = p.lines.iter().map(|l| l.role).collect();
1260 assert_eq!(
1261 roles,
1262 vec![
1263 ChatRole::Agent, ChatRole::Tool,
1265 ChatRole::Tool,
1266 ChatRole::Agent, ],
1268 "human order: speech intro then tools: {roles:?}"
1269 );
1270 assert!(
1271 p.lines[0].text.contains("I'll run the CRUD steps"),
1272 "intro first: {:?}",
1273 p.lines[0]
1274 );
1275 let emit = project_chat_with(
1277 &events,
1278 &ProjectChatOptions {
1279 order_by_source_time: false,
1280 allow_structural_zip: false,
1281 ..Default::default()
1282 },
1283 );
1284 assert_eq!(emit.confidence, ProjectionConfidence::EmitOrder);
1285 assert_eq!(emit.lines[0].role, ChatRole::Tool);
1286 }
1287
1288 #[test]
1289 fn without_source_times_emit_order_unchanged() {
1290 let events = vec![tool_ev("a1", "Write `f`", 1), text_ev("I'll start.", 2)];
1291 let p = project_chat_with(
1292 &events,
1293 &ProjectChatOptions {
1294 allow_structural_zip: false,
1295 order_by_source_time: true,
1296 ..Default::default()
1297 },
1298 );
1299 assert_eq!(p.confidence, ProjectionConfidence::EmitOrder);
1300 assert_eq!(p.lines[0].role, ChatRole::Tool);
1301 }
1302
1303 #[test]
1307 fn source_step_orders_when_times_absent() {
1308 let events = vec![
1309 tool_ev_step("call-a", "Create file", 1, 5),
1312 tool_ev_step("call-b", "Read file", 2, 8),
1313 text_ev_step("I'll start the CRUD.", 3, 2),
1314 text_ev_step("1. **CREATE** — done.", 4, 11),
1315 ];
1316 let p = project_chat_with(
1317 &events,
1318 &ProjectChatOptions {
1319 allow_structural_zip: false,
1320 order_by_source_time: true,
1321 ..Default::default()
1322 },
1323 );
1324 assert_eq!(p.confidence, ProjectionConfidence::DialectSourceStep);
1325 assert!(
1326 p.strategy_reason.contains("source_step"),
1327 "{}",
1328 p.strategy_reason
1329 );
1330 let roles: Vec<_> = p.lines.iter().map(|l| l.role).collect();
1331 assert_eq!(
1332 roles,
1333 vec![
1334 ChatRole::Agent, ChatRole::Tool, ChatRole::Tool, ChatRole::Agent, ],
1339 "human order by source_step: {roles:?}"
1340 );
1341 assert!(
1342 p.lines[0].text.contains("I'll start"),
1343 "intro first: {:?}",
1344 p.lines[0]
1345 );
1346 assert!(
1347 p.plain_text.contains("s=2") || p.lines[0].source_step == Some(2),
1348 "step annotation: {}",
1349 p.plain_text
1350 );
1351 }
1352
1353 #[test]
1354 fn text_only_is_one_agent_bubble() {
1355 let p = project_chat(&[text_ev("Hello.", 1), text_ev("World.", 2)]);
1356 assert_eq!(p.strategy, ProjectionStrategy::ChronologicalChat);
1357 assert_eq!(p.lines.len(), 1);
1358 assert_eq!(p.lines[0].role, ChatRole::Agent);
1359 assert!(p.lines[0].text.contains("Hello"));
1360 assert!(p.lines[0].text.contains("World"));
1361 }
1362
1363 #[test]
1364 fn disclaimer_always_present() {
1365 let p = project_chat(&[text_ev("Hello.", 1)]);
1366 assert!(p.disclaimer.contains("not ground truth"));
1367 assert!(p.plain_text.contains("not ground truth"));
1368 assert!(p.html.contains("chat-disclaimer"));
1369 assert!(p.html.contains("EmitOrder") || p.html.contains("conf-emit"));
1370 }
1371}