Skip to main content

monoloop_testkit/
chat_projector.rs

1//! Human-digestible **chat projection** of canonical Interpreter events.
2//!
3//! **Test kit only.** This is a *report*, not ground truth.
4//!
5//! ## Design (scalable / universal)
6//!
7//! **Human chat default — dialect source order when present**
8//!
9//! Complete units often *emit* in a jumbled order relative to how a reader
10//! expects the turn (sentence assembly waits while tools complete). When the
11//! dialect supplies `source_time` (Grok `agentTimestampMs`) and/or
12//! `source_step` (Antigravity `_meta.stepIdx` / numeric `messageId`), the
13//! chat report sorts by those observational keys so humans see production
14//! order. Missing keys fall back to emit order. Canonical timeline /
15//! interleaved stream stay emit-order ground truth.
16//!
17//! **Fallback core — emit-order chronological chat**
18//!
19//! Map channels to chat roles, collapse consecutive same-role text. No invented
20//! speech, no tool↔sentence claims.
21//!
22//! **Optional structural zip (gated)**
23//!
24//! Only when *all* of these hold:
25//! 1. Every tool first-sight precedes every public-response sentence (tools-first dump)
26//! 2. Public text contains numbered list items (`1. …`, `2. …`)
27//! 3. Count of those list items **equals** the tool count
28//!
29//! Then: preamble → thinking/status → ordinal `(tool[i], step[i])` → epilogue.
30//! Pairing is **index-only** — no CREATE/READ/keyword matching.
31//!
32//! If any gate fails → chronological (source-time or emit). Prefer honesty over
33//! a pretty but wrong weave.
34//!
35//! ```text
36//! AGENT:    I'll take care of that.
37//! THINKING: … should read the file first
38//! TOOL:     Read path/to/file.txt
39//! AGENT:    Aha — let me update it.
40//! TOOL:     Write path/to/file.txt
41//! AGENT:    Done; file is ready.
42//! ```
43
44use monoloop_contracts::{
45    CanonicalUnit, InterpreterOutputEvent, SourceTimeObservation, TextChannel, ToolRequestState,
46    UnitState,
47};
48use std::collections::HashMap;
49
50/// Strategy used to lay out the projected chat.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum ProjectionStrategy {
53    /// Chronological layout with chat chrome (source-time or emit order).
54    ChronologicalChat,
55    /// Tools-first dump + equal numbered steps → ordinal tool/step zip only.
56    StructuralOrdinalZip,
57}
58
59/// Confidence of the projection relative to Interpreter emit order.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ProjectionConfidence {
62    /// Layout follows emit order (no reordering of tools vs text).
63    EmitOrder,
64    /// Chronological blocks reordered by dialect `source_time.first_ms` for
65    /// human readability. Not causality; content still complete units only.
66    DialectSourceTime,
67    /// Chronological blocks reordered by dialect `source_step` (stream sequence
68    /// id) when wall-clock source times are absent. Observational only.
69    DialectSourceStep,
70    /// Tools/steps reordered under structural gates; still no invented content.
71    StructuralReorder,
72}
73
74/// Options for [`project_chat_with`].
75#[derive(Clone, Debug)]
76pub struct ProjectChatOptions {
77    /// When true (default), allow [`ProjectionStrategy::StructuralOrdinalZip`]
78    /// if structural gates pass. When false, always chronological.
79    pub allow_structural_zip: bool,
80    /// When true (default), sort chronological blocks by dialect observational
81    /// order when present: `source_time.first_ms`, then `source_step`, then emit
82    /// index. When neither time nor step is present, emit order is preserved.
83    /// Set false to force pure emit-order chat.
84    pub order_by_source_time: bool,
85    /// When true (default), annotate lines with dialect source-time / source-step
86    /// when known.
87    pub annotate_source_time: bool,
88}
89
90impl Default for ProjectChatOptions {
91    fn default() -> Self {
92        Self {
93            allow_structural_zip: true,
94            // Human product surface: avoid jumbled tools-before-speech when the
95            // dialect clock is available. Kernel emit order remains elsewhere.
96            order_by_source_time: true,
97            annotate_source_time: true,
98        }
99    }
100}
101
102/// Role of a projected chat line.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum ChatRole {
105    /// Public assistant speech.
106    Agent,
107    /// Public reasoning summary (never private CoT).
108    Thinking,
109    /// Tool action card.
110    Tool,
111    /// Status / narration channel.
112    Status,
113}
114
115/// Compact tool snapshot for projection.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct ProjectedTool {
118    /// Dialect tool action id.
119    pub action_id: String,
120    /// Display title (often path-aware from the dialect).
121    pub title: String,
122    /// Short display verb (`Write`, `Read`, first token, …) — cosmetic only.
123    pub verb: String,
124    /// Request args JSON when ready (bounded later by the HTML layer).
125    pub args: Option<String>,
126    /// Terminal outcome label when known (`Success`, …).
127    pub terminal: Option<String>,
128    /// Request/unit state label.
129    pub state: String,
130    /// Dialect source time when present on the unit snapshot.
131    pub source_time: Option<SourceTimeObservation>,
132    /// Dialect stream step when present on the unit snapshot.
133    pub source_step: Option<u64>,
134}
135
136/// One line in the projected chat.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct ChatLine {
139    /// Speaker / surface.
140    pub role: ChatRole,
141    /// Markdown (or plain) body for agent/thinking/status lines.
142    pub text: String,
143    /// Tool payload when `role == Tool`.
144    pub tool: Option<ProjectedTool>,
145    /// True when this line was placed by structural zip (not pure emit order).
146    pub reordered: bool,
147    /// Dialect source time when present (observational).
148    pub source_time: Option<SourceTimeObservation>,
149    /// Dialect stream step when present (observational).
150    pub source_step: Option<u64>,
151}
152
153/// Full chat projection for a run.
154#[derive(Clone, Debug)]
155pub struct ChatProjection {
156    /// Layout strategy chosen for this run.
157    pub strategy: ProjectionStrategy,
158    /// Confidence relative to emit order.
159    pub confidence: ProjectionConfidence,
160    /// Why this strategy was chosen (short, for UI / tests).
161    pub strategy_reason: String,
162    /// Projected lines in display order.
163    pub lines: Vec<ChatLine>,
164    /// Plain-text chat transcript for terminals / logs.
165    pub plain_text: String,
166    /// Self-contained HTML fragment (section body only).
167    pub html: String,
168    /// Human-facing disclaimer (always present).
169    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
180/// Project canonical Interpreter events into a chat-like flow (default options).
181pub fn project_chat(events: &[InterpreterOutputEvent]) -> ChatProjection {
182    project_chat_with(events, &ProjectChatOptions::default())
183}
184
185/// Project with explicit options.
186pub 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
249// ── extraction ──────────────────────────────────────────────────────────────
250
251struct Extracted {
252    /// Event-order blocks (text or first-sight tool).
253    chrono: Vec<ChronoBlock>,
254    /// Tools in first-sight order, upgraded to latest generation.
255    tools: Vec<ProjectedTool>,
256    /// Public response sentences in emit order.
257    public: Vec<String>,
258    /// Reasoning-summary sentences (also present in chrono).
259    reasoning: Vec<String>,
260    /// Status narration sentences (also present in chrono).
261    status: Vec<String>,
262    /// True if every tool first-sight precedes every public sentence.
263    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/// Which dialect observational key (if any) changed display order vs emit.
281#[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
418/// Sort chrono blocks by dialect observational keys when present.
419///
420/// Preference: wall-clock `source_time.first_ms` (Grok), then stream
421/// `source_step` (Antigravity stepIdx/messageId), then emit index.
422fn 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
489/// Cosmetic label for tool cards only — never used for pairing.
490fn 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
522// ── strategy ────────────────────────────────────────────────────────────────
523
524fn 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
575// ── assembly ────────────────────────────────────────────────────────────────
576
577fn 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    // Require "N." or "N. " form (digit run + period). Content may follow.
584    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    // Reasoning/status that appeared in the stream: for tools-first dumps they
611    // usually sit with tools or after; surface them before the weave as context.
612    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    // Gates guarantee steps.len() == tools.len().
623    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
774/// Collapse adjacent same-role non-tool bubbles for readability.
775fn 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
811// ── render ──────────────────────────────────────────────────────────────────
812
813fn 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("&amp;"),
1006            '<' => out.push_str("&lt;"),
1007            '>' => out.push_str("&gt;"),
1008            '"' => out.push_str("&quot;"),
1009            '\'' => out.push_str("&#39;"),
1010            c => out.push(c),
1011        }
1012    }
1013    out
1014}
1015
1016// ── tests ───────────────────────────────────────────────────────────────────
1017
1018#[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        // Preamble, then tool/step pairs.
1148        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        // Ordinal: first tool before first step text.
1158        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        // All tools before text in emit order.
1176        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    /// Live Grok shape: tools emit before the complete sentence, but dialect
1234    /// source times show speech started earlier — human chat must not look jumbled.
1235    #[test]
1236    fn source_time_puts_earlier_speech_before_later_tools() {
1237        let events = vec![
1238            // Emit order: tools first (sentence still assembling when tools finished).
1239            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, // intro — earlier first_ms
1264                ChatRole::Tool,
1265                ChatRole::Tool,
1266                ChatRole::Agent, // list step — later first_ms
1267            ],
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        // Pure emit order would still show tools first when source-time is forced off.
1276        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    /// Antigravity-like: no wall-clock times, but stream `source_step` present.
1304    /// Emit order can put later-step text before earlier-step tools (or reverse);
1305    /// human chat follows stepIdx / messageId.
1306    #[test]
1307    fn source_step_orders_when_times_absent() {
1308        let events = vec![
1309            // Emit: tools first, then text — but text has lower step than one tool
1310            // (simulate out-of-order completion relative to dialect sequence).
1311            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, // step 2
1335                ChatRole::Tool,  // step 5
1336                ChatRole::Tool,  // step 8
1337                ChatRole::Agent, // step 11
1338            ],
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}