1use crate::chat_projector::{project_chat, ChatProjection};
14use monoloop_contracts::{
15 BoundaryKind, CanonicalUnit, InterpretationEnd, InterpreterOutputEvent, SourceTimeObservation,
16 StructureKind, TextChannel, ToolRequestState, UnitState,
17};
18use pulldown_cmark::{html, Options, Parser};
19use std::path::Path;
20
21#[derive(Clone, Debug)]
23pub struct HtmlReportParams {
24 pub include_timeline: bool,
26 pub include_reasoning: bool,
28 pub include_chat_projection: bool,
30 pub show_tool_payloads: bool,
32 pub max_payload_chars: usize,
34 pub title: String,
36}
37
38impl Default for HtmlReportParams {
39 fn default() -> Self {
40 Self {
41 include_timeline: true,
42 include_reasoning: true,
43 include_chat_projection: true,
44 show_tool_payloads: true,
45 max_payload_chars: 800,
46 title: "Monoloop interpretation review".into(),
47 }
48 }
49}
50
51#[derive(Clone, Debug)]
53pub struct HtmlReport {
54 pub assembled_markdown: String,
56 pub document_html: String,
58 pub interleaved_html: String,
60 pub chat_projection: ChatProjection,
62 pub full_page_html: String,
64 pub sentence_count: usize,
66 pub timeline_rows: usize,
68}
69
70#[derive(Clone, Debug)]
72enum DocBlock {
73 Text(String),
75 Tool {
77 action_id: String,
78 name: String,
79 state: String,
80 args: Option<String>,
81 terminal: Option<String>,
82 },
83}
84
85pub fn build_html_report(
87 events: &[InterpreterOutputEvent],
88 params: &HtmlReportParams,
89) -> HtmlReport {
90 let mut public_sentences: Vec<String> = Vec::new();
91 let mut reasoning_sentences: Vec<String> = Vec::new();
92 let mut interleaved: Vec<DocBlock> = Vec::new();
93 let mut timeline: Vec<TimelineRow> = Vec::new();
94 let mut end: Option<&InterpretationEnd> = None;
95 let mut tool_latest: std::collections::HashMap<String, DocBlock> =
97 std::collections::HashMap::new();
98 let mut tool_order: Vec<String> = Vec::new();
99
100 for ev in events {
101 match ev {
102 InterpreterOutputEvent::Unit(unit_ev) => {
103 let snap = unit_ev.snapshot();
104 match &snap.unit {
105 CanonicalUnit::Text(t) => {
106 match t.channel {
107 TextChannel::PublicResponse => {
108 public_sentences.push(t.content.clone());
109 interleaved.push(DocBlock::Text(t.content.clone()));
110 }
111 TextChannel::PublicReasoningSummary => {
112 reasoning_sentences.push(t.content.clone());
113 }
114 TextChannel::StatusNarration | TextChannel::QuotedExternalContent => {}
115 }
116 timeline.push(TimelineRow {
117 lifecycle: unit_ev.lifecycle_label().to_string(),
118 kind: "text".into(),
119 state: format!("{:?}", snap.unit_state),
120 label: t.channel.label().to_string(),
121 correlation: format!(
122 "c:{} i:{} u:{} g:{}{}",
123 short(snap.connection_id.as_str()),
124 short(snap.interpretation_id.as_str()),
125 short(snap.unit_id.as_str()),
126 snap.unit_generation,
127 source_obs_corr(snap.source_time, snap.source_step)
128 ),
129 body: t.content.clone(),
130 css_class: "ev-text".into(),
131 });
132 }
133 CanonicalUnit::Tool(t) => {
134 let name = t.tool_name.as_deref().unwrap_or("?").to_string();
135 let mut body = format!(
136 "request={:?} exec={:?} result={:?}",
137 t.request_state, t.execution_state, t.result_state
138 );
139 if let Some(ref w) = t.waiting_for {
140 body.push_str(&format!(" waiting_for={w}"));
141 }
142 let args = if params.show_tool_payloads {
143 t.request_payload.as_ref().and_then(|p| {
144 if t.request_state == ToolRequestState::Ready
145 || t.terminal_outcome.is_some()
146 {
147 Some(truncate(p, params.max_payload_chars))
148 } else {
149 None
150 }
151 })
152 } else {
153 None
154 };
155 if let Some(ref p) = args {
156 body.push_str(" args=");
157 body.push_str(p);
158 }
159 let action_id = t.tool_action_id.as_str().to_string();
160 let terminal = t.terminal_outcome.map(|o| format!("{o:?}"));
161 let state = tool_state_label(t.request_state, snap.unit_state);
162 let block = DocBlock::Tool {
164 action_id: action_id.clone(),
165 name: name.clone(),
166 state: state.clone(),
167 args: args.clone(),
168 terminal: terminal.clone(),
169 };
170 if !tool_latest.contains_key(&action_id) {
171 tool_order.push(action_id.clone());
172 interleaved.push(block.clone());
174 } else {
175 if let Some(DocBlock::Tool { .. }) =
177 interleaved.iter_mut().find(|b| match b {
178 DocBlock::Tool { action_id: id, .. } => id == &action_id,
179 _ => false,
180 })
181 {
182 *interleaved
183 .iter_mut()
184 .find(|b| match b {
185 DocBlock::Tool { action_id: id, .. } => id == &action_id,
186 _ => false,
187 })
188 .unwrap() = block.clone();
189 }
190 }
191 tool_latest.insert(action_id.clone(), block);
192
193 timeline.push(TimelineRow {
194 lifecycle: unit_ev.lifecycle_label().to_string(),
195 kind: "tool".into(),
196 state,
197 label: name,
198 correlation: format!(
199 "c:{} i:{} action:{} g:{}{}",
200 short(snap.connection_id.as_str()),
201 short(snap.interpretation_id.as_str()),
202 t.tool_action_id.as_str(),
203 snap.unit_generation,
204 source_obs_corr(snap.source_time, snap.source_step)
205 ),
206 body,
207 css_class: "ev-tool".into(),
208 });
209 }
210 CanonicalUnit::Structure(st) => {
211 timeline.push(TimelineRow {
212 lifecycle: unit_ev.lifecycle_label().to_string(),
213 kind: format!("structure/{:?}", st.kind),
214 state: format!("{:?}", snap.unit_state),
215 label: structure_label(st.kind).into(),
216 correlation: format!(
217 "u:{} g:{}",
218 short(snap.unit_id.as_str()),
219 snap.unit_generation
220 ),
221 body: st.content.clone(),
222 css_class: "ev-structure".into(),
223 });
224 }
225 CanonicalUnit::Boundary(b) => {
226 timeline.push(TimelineRow {
227 lifecycle: unit_ev.lifecycle_label().to_string(),
228 kind: "boundary".into(),
229 state: "complete".into(),
230 label: boundary_label(b.kind).into(),
231 correlation: format!("u:{}", short(snap.unit_id.as_str())),
232 body: String::new(),
233 css_class: "ev-boundary".into(),
234 });
235 }
236 CanonicalUnit::Diagnostic(d) => {
237 timeline.push(TimelineRow {
238 lifecycle: unit_ev.lifecycle_label().to_string(),
239 kind: "diagnostic".into(),
240 state: format!("{:?}", d.kind),
241 label: "diag".into(),
242 correlation: format!("u:{}", short(snap.unit_id.as_str())),
243 body: d.message.clone(),
244 css_class: "ev-diag".into(),
245 });
246 }
247 CanonicalUnit::Paragraph(p) => {
248 timeline.push(TimelineRow {
249 lifecycle: unit_ev.lifecycle_label().to_string(),
250 kind: format!("paragraph/{:?}", p.kind),
251 state: "complete".into(),
252 label: "¶".into(),
253 correlation: format!("u:{}", short(snap.unit_id.as_str())),
254 body: String::new(),
255 css_class: "ev-para".into(),
256 });
257 }
258 CanonicalUnit::Usage(u) => {
259 timeline.push(TimelineRow {
260 lifecycle: unit_ev.lifecycle_label().to_string(),
261 kind: "usage".into(),
262 state: "complete".into(),
263 label: "tokens".into(),
264 correlation: String::new(),
265 body: format!("{u:?}"),
266 css_class: "ev-usage".into(),
267 });
268 }
269 }
270 }
271 InterpreterOutputEvent::Ended(e) => {
272 end = Some(e);
273 timeline.push(TimelineRow {
274 lifecycle: "ended".into(),
275 kind: "interpretation".into(),
276 state: format!("{:?}", e.kind),
277 label: "end".into(),
278 correlation: format!(
279 "c:{} i:{}",
280 short(e.connection_id.as_str()),
281 short(e.interpretation_id.as_str())
282 ),
283 body: format!(
284 "events={} sentences={} unresolved_bytes={}",
285 e.canonical_event_count,
286 e.completed_sentence_count,
287 e.unresolved_text_bytes
288 ),
289 css_class: "ev-end".into(),
290 });
291 }
292 }
293 }
294
295 let _ = tool_order; let _ = tool_latest;
297
298 let assembled_markdown = join_sentences_as_markdown(&public_sentences);
299 let document_html = markdown_to_html(&assembled_markdown);
300 let interleaved_html = render_interleaved(&interleaved, params);
301 let chat_projection = project_chat(events);
302
303 let reasoning_md = if params.include_reasoning && !reasoning_sentences.is_empty() {
304 join_sentences_as_markdown(&reasoning_sentences)
305 } else {
306 String::new()
307 };
308 let reasoning_html = if reasoning_md.is_empty() {
309 String::new()
310 } else {
311 markdown_to_html(&reasoning_md)
312 };
313
314 let sentence_count = public_sentences.len();
315 let timeline_rows = timeline.len();
316 let full_page_html = render_full_page(
317 params,
318 &assembled_markdown,
319 &document_html,
320 &interleaved_html,
321 &chat_projection,
322 &reasoning_html,
323 &timeline,
324 end,
325 );
326
327 HtmlReport {
328 assembled_markdown,
329 document_html,
330 interleaved_html,
331 chat_projection,
332 full_page_html,
333 sentence_count,
334 timeline_rows,
335 }
336}
337
338fn render_interleaved(blocks: &[DocBlock], params: &HtmlReportParams) -> String {
339 let mut out = String::new();
340 let mut text_buf: Vec<String> = Vec::new();
341
342 let flush_text = |buf: &mut Vec<String>, out: &mut String| {
343 if buf.is_empty() {
344 return;
345 }
346 let md = join_sentences_as_markdown(buf);
347 let html = markdown_to_html(&md);
348 out.push_str("<div class=\"prose stream-text\">\n");
349 out.push_str(&html);
350 out.push_str("</div>\n");
351 buf.clear();
352 };
353
354 for b in blocks {
355 match b {
356 DocBlock::Text(s) => text_buf.push(s.clone()),
357 DocBlock::Tool {
358 action_id,
359 name,
360 state,
361 args,
362 terminal,
363 } => {
364 flush_text(&mut text_buf, &mut out);
365 out.push_str("<div class=\"tool-card\">\n");
366 out.push_str(&format!(
367 "<div class=\"tool-hdr\"><span class=\"tool-name\">{}</span> \
368 <span class=\"tool-state\">{}</span>",
369 escape_html(name),
370 escape_html(state)
371 ));
372 if let Some(t) = terminal {
373 out.push_str(&format!(
374 " <span class=\"tool-term\">→ {}</span>",
375 escape_html(t)
376 ));
377 }
378 out.push_str(&format!(
379 " <span class=\"tool-id\">{}</span></div>\n",
380 escape_html(action_id)
381 ));
382 if params.show_tool_payloads {
383 if let Some(a) = args {
384 out.push_str(&format!(
385 "<pre class=\"tool-args\">{}</pre>\n",
386 escape_html(a)
387 ));
388 }
389 }
390 out.push_str("</div>\n");
391 }
392 }
393 }
394 flush_text(&mut text_buf, &mut out);
395 if out.is_empty() {
396 out.push_str("<p class=\"empty\"><em>(no interleaved content)</em></p>\n");
397 }
398 out
399}
400
401pub fn write_html_report(path: impl AsRef<Path>, report: &HtmlReport) -> std::io::Result<()> {
403 let path = path.as_ref();
404 if let Some(parent) = path.parent() {
405 if !parent.as_os_str().is_empty() {
406 std::fs::create_dir_all(parent)?;
407 }
408 }
409 std::fs::write(path, report.full_page_html.as_bytes())
410}
411
412pub fn join_sentences_as_markdown(sentences: &[String]) -> String {
418 let mut out = String::new();
419 for s in sentences {
420 let t = s.trim();
421 if t.is_empty() {
422 continue;
423 }
424 if out.is_empty() {
425 out.push_str(t);
426 continue;
427 }
428 if looks_like_md_list_item(t) {
429 out.push('\n');
430 out.push_str(t);
431 } else {
432 out.push_str("\n\n");
433 out.push_str(t);
434 }
435 }
436 out
437}
438
439fn looks_like_md_list_item(s: &str) -> bool {
440 let b = s.as_bytes();
441 let mut i = 0;
442 while i < b.len() && b[i].is_ascii_digit() {
443 i += 1;
444 }
445 i > 0 && i < b.len() && b[i] == b'.'
446}
447
448pub fn markdown_to_html(md: &str) -> String {
450 let mut options = Options::empty();
451 options.insert(Options::ENABLE_TABLES);
452 options.insert(Options::ENABLE_STRIKETHROUGH);
453 options.insert(Options::ENABLE_TASKLISTS);
454 let parser = Parser::new_ext(md, options);
455 let mut out = String::new();
456 html::push_html(&mut out, parser);
457 out
458}
459
460#[derive(Clone, Debug)]
461struct TimelineRow {
462 lifecycle: String,
463 kind: String,
464 state: String,
465 label: String,
466 correlation: String,
467 body: String,
468 css_class: String,
469}
470
471fn render_full_page(
472 params: &HtmlReportParams,
473 assembled_md: &str,
474 document_html: &str,
475 interleaved_html: &str,
476 chat: &ChatProjection,
477 reasoning_html: &str,
478 timeline: &[TimelineRow],
479 end: Option<&InterpretationEnd>,
480) -> String {
481 let mut body = String::new();
482 body.push_str(&format!("<h1>{}</h1>\n", escape_html(¶ms.title)));
483 body.push_str(
484 "<p class=\"meta\">Built from <strong>canonical Interpreter events only</strong> \
485 — not a re-parse of raw Grok wire bytes. Sections below mix a human-facing \
486 chat projection (report) with exact event-order views (ground truth).</p>\n",
487 );
488
489 if let Some(e) = end {
490 body.push_str(&format!(
491 "<p class=\"meta end\">Interpretation end: <code>{:?}</code> · \
492 canonical events: {} · sentences: {} · unresolved text bytes: {}</p>\n",
493 e.kind, e.canonical_event_count, e.completed_sentence_count, e.unresolved_text_bytes
494 ));
495 }
496
497 if params.include_chat_projection {
498 body.push_str("<section id=\"chat\">\n");
499 body.push_str("<h2>Chat projection <span class=\"badge-report\">report</span></h2>\n");
500 body.push_str(
501 "<p class=\"meta\">Human-digestible reassembly of agent / thinking / tool \
502 surfaces. May reorder tools against later summary text. \
503 <strong>Not ground truth.</strong></p>\n",
504 );
505 body.push_str(&chat.html);
506 body.push_str("<details><summary>Plain-text chat transcript</summary>\n");
507 body.push_str("<pre class=\"md-source\">");
508 body.push_str(&escape_html(&chat.plain_text));
509 body.push_str("</pre></details>\n");
510 body.push_str("</section>\n");
511 }
512
513 body.push_str("<section id=\"interleaved\">\n");
514 body.push_str("<h2>Interleaved stream <span class=\"badge-truth\">event order</span></h2>\n");
515 body.push_str(
516 "<p class=\"meta\">Tools appear at first sighting (card updates to terminal state). \
517 Text blocks flush between tools. This is the order the Interpreter emitted units.</p>\n",
518 );
519 body.push_str(interleaved_html);
520 body.push_str("</section>\n");
521
522 body.push_str("<section id=\"document\">\n");
523 body.push_str("<h2>Text-only assembly</h2>\n");
524 body.push_str(
525 "<p class=\"meta\">Public response sentences only → Markdown → HTML \
526 (no tools). Good for checking list/sentence segmentation.</p>\n",
527 );
528 if document_html.trim().is_empty() {
529 body.push_str("<p class=\"empty\"><em>(no complete public_response sentences)</em></p>\n");
530 } else {
531 body.push_str("<div class=\"prose\">\n");
532 body.push_str(document_html);
533 body.push_str("</div>\n");
534 }
535 body.push_str("<details><summary>Source Markdown (text-only)</summary>\n");
536 body.push_str("<pre class=\"md-source\">");
537 body.push_str(&escape_html(assembled_md));
538 body.push_str("</pre></details>\n");
539 body.push_str("</section>\n");
540
541 if !reasoning_html.is_empty() {
542 body.push_str("<section id=\"reasoning\">\n");
543 body.push_str("<h2>Reasoning summary</h2>\n");
544 body.push_str("<div class=\"prose reasoning\">\n");
545 body.push_str(reasoning_html);
546 body.push_str("</div></section>\n");
547 }
548
549 if params.include_timeline {
550 body.push_str("<section id=\"timeline\">\n");
551 body.push_str("<h2>Canonical event timeline</h2>\n");
552 body.push_str(
553 "<p class=\"meta\">Every unit generation as emitted by the Interpreter \
554 (append-only order).</p>\n",
555 );
556 body.push_str("<ol class=\"timeline\">\n");
557 for row in timeline {
558 body.push_str(&format!(
559 "<li class=\"{}\"><div class=\"hdr\"><span class=\"kind\">{}</span> \
560 <span class=\"state\">{}/{}</span> <span class=\"label\">{}</span> \
561 <span class=\"corr\">{}</span></div>",
562 escape_html(&row.css_class),
563 escape_html(&row.kind),
564 escape_html(&row.lifecycle),
565 escape_html(&row.state),
566 escape_html(&row.label),
567 escape_html(&row.correlation),
568 ));
569 if !row.body.is_empty() {
570 body.push_str(&format!(
571 "<pre class=\"body\">{}</pre>",
572 escape_html(&row.body)
573 ));
574 }
575 body.push_str("</li>\n");
576 }
577 body.push_str("</ol></section>\n");
578 }
579
580 format!(
581 r#"<!DOCTYPE html>
582<html lang="en">
583<head>
584<meta charset="utf-8"/>
585<meta name="viewport" content="width=device-width, initial-scale=1"/>
586<title>{title}</title>
587<style>
588{css}
589</style>
590</head>
591<body>
592{body}
593</body>
594</html>
595"#,
596 title = escape_html(¶ms.title),
597 css = PAGE_CSS,
598 body = body,
599 )
600}
601
602const PAGE_CSS: &str = r#"
603:root {
604 --bg: #0f1419;
605 --panel: #1a2332;
606 --text: #e7ecf3;
607 --muted: #8b9bb4;
608 --accent: #5b9fd4;
609 --tool: #c9a227;
610 --diag: #d46b6b;
611 --border: #2a3548;
612 --code: #0d1117;
613}
614* { box-sizing: border-box; }
615body {
616 margin: 0 auto;
617 max-width: 920px;
618 padding: 1.5rem 1.25rem 3rem;
619 font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
620 background: var(--bg);
621 color: var(--text);
622 line-height: 1.55;
623}
624h1 { font-size: 1.45rem; margin: 0 0 0.5rem; }
625h2 { font-size: 1.15rem; margin: 1.75rem 0 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
626.meta { color: var(--muted); font-size: 0.92rem; }
627.meta.end { background: var(--panel); padding: 0.6rem 0.8rem; border-radius: 6px; border: 1px solid var(--border); }
628.prose {
629 background: var(--panel);
630 border: 1px solid var(--border);
631 border-radius: 8px;
632 padding: 1rem 1.15rem;
633}
634.prose p { margin: 0 0 0.85rem; }
635.prose p:last-child { margin-bottom: 0; }
636.prose code, .prose pre {
637 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
638 font-size: 0.9em;
639}
640.prose pre {
641 background: var(--code);
642 padding: 0.75rem;
643 border-radius: 6px;
644 overflow-x: auto;
645}
646.prose.reasoning { border-left: 3px solid var(--accent); }
647.empty { color: var(--muted); }
648details { margin-top: 0.75rem; color: var(--muted); }
649.md-source {
650 background: var(--code);
651 padding: 0.75rem;
652 border-radius: 6px;
653 overflow-x: auto;
654 white-space: pre-wrap;
655 word-break: break-word;
656 font-size: 0.85rem;
657}
658.timeline { list-style: none; padding: 0; margin: 0; }
659.timeline li {
660 background: var(--panel);
661 border: 1px solid var(--border);
662 border-radius: 8px;
663 padding: 0.65rem 0.8rem;
664 margin: 0 0 0.55rem;
665}
666.timeline .hdr { font-size: 0.85rem; display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; align-items: baseline; }
667.timeline .kind { color: var(--accent); font-weight: 600; }
668.timeline .state { color: var(--muted); }
669.timeline .label { font-weight: 600; }
670.timeline .corr { color: var(--muted); font-family: ui-monospace, monospace; font-size: 0.8rem; }
671.timeline .body {
672 margin: 0.45rem 0 0;
673 padding: 0.5rem 0.6rem;
674 background: var(--code);
675 border-radius: 4px;
676 font-size: 0.85rem;
677 white-space: pre-wrap;
678 word-break: break-word;
679 overflow-x: auto;
680}
681.ev-tool .kind, .ev-tool .label { color: var(--tool); }
682.ev-diag .kind { color: var(--diag); }
683.ev-end { border-color: var(--accent); }
684.tool-card {
685 background: var(--panel);
686 border: 1px solid var(--tool);
687 border-left: 4px solid var(--tool);
688 border-radius: 8px;
689 padding: 0.65rem 0.85rem;
690 margin: 0.65rem 0;
691}
692.tool-hdr { font-size: 0.9rem; display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; }
693.tool-name { color: var(--tool); font-weight: 700; }
694.tool-state { color: var(--muted); }
695.tool-term { color: #7dcea0; font-weight: 600; }
696.tool-id { color: var(--muted); font-family: ui-monospace, monospace; font-size: 0.78rem; }
697.tool-args {
698 margin: 0.45rem 0 0;
699 padding: 0.5rem 0.6rem;
700 background: var(--code);
701 border-radius: 4px;
702 font-size: 0.82rem;
703 white-space: pre-wrap;
704 word-break: break-word;
705}
706.stream-text { margin: 0.75rem 0; }
707.badge-report, .badge-truth {
708 font-size: 0.7rem;
709 font-weight: 600;
710 vertical-align: middle;
711 margin-left: 0.35rem;
712 padding: 0.12rem 0.45rem;
713 border-radius: 999px;
714 letter-spacing: 0.02em;
715 text-transform: uppercase;
716}
717.badge-report { background: #5c3d1e; color: #f0c27a; border: 1px solid #c9a227; }
718.badge-truth { background: #1e3a2f; color: #7dcea0; border: 1px solid #3d7a5c; }
719.chat-projection { margin: 0.5rem 0 1rem; }
720.chat-disclaimer {
721 background: #3a2a14;
722 border: 1px solid #c9a227;
723 border-radius: 8px;
724 padding: 0.65rem 0.85rem;
725 color: #f0c27a;
726 font-size: 0.88rem;
727 margin-bottom: 0.75rem;
728}
729.chat-strategy { color: var(--muted); font-size: 0.85rem; }
730.chat-reason { color: var(--muted); font-size: 0.82rem; }
731.chat-projection.conf-emit .chat-strategy code:first-of-type { color: #7dcea0; }
732.chat-projection.conf-source-time .chat-strategy code:first-of-type { color: #85c1e9; }
733.chat-projection.conf-structural .chat-strategy code:first-of-type { color: #f0c27a; }
734.chat-source-time { color: var(--muted); font-size: 0.8rem; font-weight: 500; font-family: ui-monospace, monospace; }
735.chat-flow { display: flex; flex-direction: column; gap: 0.55rem; }
736.chat-line {
737 border-radius: 10px;
738 padding: 0.55rem 0.8rem;
739 border: 1px solid var(--border);
740 background: var(--panel);
741}
742.chat-line.agent { border-left: 4px solid var(--accent); }
743.chat-line.thinking {
744 border-left: 4px solid #8b7ec8;
745 opacity: 0.95;
746 font-style: italic;
747}
748.chat-line.tool { border-left: 4px solid var(--tool); }
749.chat-line.status { border-left: 4px solid var(--muted); }
750.chat-line.reordered { box-shadow: inset 0 0 0 1px rgba(201, 162, 39, 0.25); }
751.chat-role {
752 font-size: 0.72rem;
753 text-transform: uppercase;
754 letter-spacing: 0.06em;
755 color: var(--muted);
756 font-weight: 700;
757 margin-bottom: 0.25rem;
758}
759.chat-body p { margin: 0 0 0.5rem; }
760.chat-body p:last-child { margin-bottom: 0; }
761.chat-body code {
762 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
763 font-size: 0.9em;
764}
765.chat-tool-card { font-size: 0.92rem; }
766.chat-tool-verb { color: var(--tool); font-weight: 700; margin-right: 0.35rem; }
767.chat-tool-title { font-weight: 600; }
768.chat-tool-term { color: #7dcea0; font-weight: 600; margin-left: 0.35rem; }
769.chat-tool-state { color: var(--muted); font-size: 0.8rem; margin-left: 0.35rem; }
770.chat-tool-args {
771 margin: 0.4rem 0 0;
772 padding: 0.45rem 0.55rem;
773 background: var(--code);
774 border-radius: 4px;
775 font-size: 0.8rem;
776 white-space: pre-wrap;
777 word-break: break-word;
778}
779"#;
780
781fn escape_html(s: &str) -> String {
782 let mut out = String::with_capacity(s.len());
783 for c in s.chars() {
784 match c {
785 '&' => out.push_str("&"),
786 '<' => out.push_str("<"),
787 '>' => out.push_str(">"),
788 '"' => out.push_str("""),
789 '\'' => out.push_str("'"),
790 c => out.push(c),
791 }
792 }
793 out
794}
795
796fn short(id: &str) -> &str {
797 if id.len() <= 10 {
798 id
799 } else {
800 &id[..10]
801 }
802}
803
804fn source_obs_corr(st: Option<SourceTimeObservation>, step: Option<u64>) -> String {
806 let mut out = String::new();
807 match st {
808 Some(s) if s.first_ms == s.last_ms => out.push_str(&format!(" t:{}", s.first_ms)),
809 Some(s) => out.push_str(&format!(" t:{}..{}", s.first_ms, s.last_ms)),
810 None => {}
811 }
812 if let Some(s) = step {
813 out.push_str(&format!(" s:{s}"));
814 }
815 out
816}
817
818fn truncate(s: &str, max: usize) -> String {
819 if s.chars().count() <= max {
820 s.to_string()
821 } else {
822 let t: String = s.chars().take(max).collect();
823 format!("{t}…")
824 }
825}
826
827fn tool_state_label(req: ToolRequestState, unit: UnitState) -> String {
828 let base = match req {
829 ToolRequestState::Ready => "ready",
830 ToolRequestState::Assembling => "waiting",
831 ToolRequestState::Incomplete => "incomplete",
832 ToolRequestState::Malformed => "malformed",
833 };
834 if unit == UnitState::Complete {
835 format!("{base}/complete")
836 } else {
837 base.to_string()
838 }
839}
840
841fn structure_label(k: StructureKind) -> &'static str {
842 match k {
843 StructureKind::Heading => "heading",
844 StructureKind::ListItem => "list_item",
845 StructureKind::CodeBlock => "code",
846 StructureKind::TableRow => "table_row",
847 StructureKind::BlockQuote => "quote",
848 StructureKind::ThematicBreak => "hr",
849 StructureKind::RawBlock => "raw",
850 }
851}
852
853fn boundary_label(k: BoundaryKind) -> &'static str {
854 match k {
855 BoundaryKind::ResponseStarted => "response_started",
856 BoundaryKind::ChannelStarted => "channel_started",
857 BoundaryKind::ChannelFinished => "channel_finished",
858 BoundaryKind::ResponseFinished => "response_finished",
859 BoundaryKind::UsageFinalized => "usage_finalized",
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use monoloop_contracts::{
867 CanonicalUnit, CanonicalUnitEvent, CanonicalUnitSnapshot, ConnectionId, FlowId,
868 InterpretationEndKind, InterpretationId, LaneId, TextSentence, UnitId,
869 };
870
871 fn text_ev(content: &str, n: u64) -> InterpreterOutputEvent {
872 InterpreterOutputEvent::Unit(Box::new(CanonicalUnitEvent::Created(
873 CanonicalUnitSnapshot {
874 unit_id: UnitId::new(format!("s{n}")),
875 unit_generation: 1,
876 unit_state: UnitState::Complete,
877 interpretation_id: InterpretationId::new("i1"),
878 connection_id: ConnectionId::new("c1"),
879 external_session_id: None,
880 flow_id: FlowId::main(),
881 lane_id: LaneId::response(),
882 lane_ordinal: n,
883 causal_parent_id: None,
884 source_time: None,
885 source_step: None,
886 unit: CanonicalUnit::Text(TextSentence {
887 sentence_id: UnitId::new(format!("s{n}")),
888 channel: TextChannel::PublicResponse,
889 paragraph_id: None,
890 sentence_ordinal: n,
891 content: content.into(),
892 }),
893 },
894 )))
895 }
896
897 #[test]
898 fn markdown_to_html_basic() {
899 let html = markdown_to_html("Hello **world**.");
900 assert!(html.contains("<strong>world</strong>") || html.contains("<p>"));
901 }
902
903 #[test]
904 fn report_assembles_sentences_and_timeline() {
905 let events = vec![
906 text_ev("Hello **world**.", 1),
907 text_ev("Second sentence!", 2),
908 InterpreterOutputEvent::Ended(InterpretationEnd {
909 interpretation_id: InterpretationId::new("i1"),
910 connection_id: ConnectionId::new("c1"),
911 external_session_id: None,
912 kind: InterpretationEndKind::Complete,
913 canonical_event_count: 2,
914 completed_sentence_count: 2,
915 completed_structure_count: 0,
916 unresolved_text_bytes: 0,
917 source_bytes_consumed: 40,
918 safe_diagnostics: vec![],
919 }),
920 ];
921 let report = build_html_report(&events, &HtmlReportParams::default());
922 assert_eq!(report.sentence_count, 2);
923 assert!(report.assembled_markdown.contains("Hello **world**."));
924 assert!(report.assembled_markdown.contains("Second sentence!"));
925 assert!(report.document_html.contains("Hello") || report.document_html.contains("world"));
926 assert!(report.full_page_html.contains("Canonical event timeline"));
927 assert!(
928 report.full_page_html.contains("Text-only assembly")
929 || report.full_page_html.contains("Interleaved stream")
930 );
931 assert!(report.full_page_html.contains("Chat projection"));
932 assert!(report
933 .chat_projection
934 .disclaimer
935 .contains("not ground truth"));
936 assert!(report.timeline_rows >= 3);
937 }
938
939 #[test]
940 fn join_keeps_ordered_list_items_adjacent() {
941 let md = join_sentences_as_markdown(&[
942 "Intro create.".into(),
943 "CRUD exercise only:".into(),
944 "1. **CREATE** — Wrote the file.".into(),
945 "2. **READ** — File contained x.".into(),
946 "No other files were touched.".into(),
947 ]);
948 assert!(md.contains("1. **CREATE**"));
949 assert!(md.contains("\n2. **READ**"));
950 assert!(!md.contains("1. **CREATE** — Wrote the file.\n\n2."));
952 let html = markdown_to_html(&md);
953 assert!(
954 html.contains("<ol>") || html.contains("<li>"),
955 "expected ordered list html: {html}"
956 );
957 assert!(
958 !html.contains("<li></li>"),
959 "empty list items mean bare markers: {html}"
960 );
961 }
962
963 #[test]
964 fn interleaved_places_tools_before_later_text() {
965 use monoloop_contracts::{
966 ToolActionEvent, ToolActionId, ToolExecutionState, ToolRequestState, ToolResultState,
967 ToolTerminalOutcome,
968 };
969 let tool = InterpreterOutputEvent::Unit(Box::new(CanonicalUnitEvent::Created(
970 CanonicalUnitSnapshot {
971 unit_id: UnitId::new("t1"),
972 unit_generation: 1,
973 unit_state: UnitState::Complete,
974 interpretation_id: InterpretationId::new("i1"),
975 connection_id: ConnectionId::new("c1"),
976 external_session_id: None,
977 flow_id: FlowId::main(),
978 lane_id: LaneId::response(),
979 lane_ordinal: 1,
980 causal_parent_id: None,
981 source_time: None,
982 source_step: None,
983 unit: CanonicalUnit::Tool(ToolActionEvent {
984 tool_action_id: ToolActionId::new("call-1"),
985 tool_name: Some("write".into()),
986 request_state: ToolRequestState::Ready,
987 execution_state: ToolExecutionState::Terminal,
988 result_state: ToolResultState::Complete,
989 request_payload: Some(r#"{"file":"x"}"#.into()),
990 result_payload: None,
991 waiting_for: None,
992 terminal_outcome: Some(ToolTerminalOutcome::Success),
993 }),
994 },
995 )));
996 let events = vec![
997 tool,
998 text_ev("1. **CREATE** — Wrote the file.", 2),
999 text_ev("Done.", 3),
1000 ];
1001 let report = build_html_report(&events, &HtmlReportParams::default());
1002 let inter = &report.interleaved_html;
1003 let tool_pos = inter.find("tool-card").expect("tool card");
1004 let text_pos = inter.find("CREATE").expect("text");
1005 assert!(
1006 tool_pos < text_pos,
1007 "tools-first stream should place tool card before summary text"
1008 );
1009 assert!(report.assembled_markdown.contains("1. **CREATE**"));
1010 }
1011}