Skip to main content

monoloop_testkit/
html_report.rs

1//! HTML projection of canonical events for visual interpretation checks.
2//!
3//! **Test kit only.** Builds a reviewable HTML page from *already assembled*
4//! canonical units — it does not re-parse raw Grok bytes or invent completeness.
5//!
6//! Layout:
7//! 1. **Chat projection** — human-digestible reassembly (report, not ground truth).
8//! 2. **Interleaved document** — event-order stream of tool actions + public
9//!    response text (Markdown → HTML); Interpreter emit order.
10//! 3. **Text-only assembly** — sentences joined for pure prose review.
11//! 4. **Event timeline** — every canonical unit generation with correlation.
12
13use 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/// Options for HTML dump generation.
22#[derive(Clone, Debug)]
23pub struct HtmlReportParams {
24    /// Include the event timeline section.
25    pub include_timeline: bool,
26    /// Include reasoning-summary channel in a separate document section.
27    pub include_reasoning: bool,
28    /// Include creative chat projection (human-digestible, not ground truth).
29    pub include_chat_projection: bool,
30    /// Show tool request payloads in the timeline (bounded).
31    pub show_tool_payloads: bool,
32    /// Max chars of tool payload shown.
33    pub max_payload_chars: usize,
34    /// Page title.
35    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/// Built HTML report from a run's canonical events.
52#[derive(Clone, Debug)]
53pub struct HtmlReport {
54    /// Markdown assembled from complete public_response sentences only.
55    pub assembled_markdown: String,
56    /// Markdown → HTML for text-only assembly.
57    pub document_html: String,
58    /// Event-order interleaved document (tools + text) as HTML.
59    pub interleaved_html: String,
60    /// Human-digestible chat projection (report, not ground truth).
61    pub chat_projection: ChatProjection,
62    /// Full self-contained HTML page (document + timeline + CSS).
63    pub full_page_html: String,
64    /// Number of complete public_response sentences used.
65    pub sentence_count: usize,
66    /// Number of timeline rows.
67    pub timeline_rows: usize,
68}
69
70/// One block in the interleaved document stream (event order).
71#[derive(Clone, Debug)]
72enum DocBlock {
73    /// Complete public-response sentence (Markdown source).
74    Text(String),
75    /// Tool lifecycle snapshot for the document (usually terminal generation).
76    Tool {
77        action_id: String,
78        name: String,
79        state: String,
80        args: Option<String>,
81        terminal: Option<String>,
82    },
83}
84
85/// Build an HTML report from interpreter output events (canonical only).
86pub 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    // Last terminal/complete tool generation per action (for interleaved view).
96    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                        // Interleaved: keep one card per action, prefer terminal generation.
163                        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                            // Insert tool card in stream position of first sighting.
173                            interleaved.push(block.clone());
174                        } else {
175                            // Update the in-stream card in place.
176                            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; // order tracked via interleaved stream
296    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
401/// Write the full HTML page to `path` (parent dirs created as needed).
402pub 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
412/// Join complete sentences into a single Markdown document body.
413///
414/// Sentences are already canonical; we only serialise them for visual review.
415/// - Ordered-list items (`1. …`) stay single-spaced from the previous block.
416/// - Other sentences get a blank line so Markdown forms paragraphs.
417pub 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
448/// Convert Markdown to HTML (pulldown-cmark). Safe for untrusted text content.
449pub 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(&params.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(&params.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("&amp;"),
786            '<' => out.push_str("&lt;"),
787            '>' => out.push_str("&gt;"),
788            '"' => out.push_str("&quot;"),
789            '\'' => out.push_str("&#39;"),
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
804/// Observational dialect source-time / source-step for timeline correlation.
805fn 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        // List items are single-newline separated (valid MD ordered list).
951        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}