Skip to main content

supercode_frontend_model/
transcript.rs

1//! Lossless projection from the SDK frontend contract into semantic UI cells.
2
3use std::collections::HashMap;
4
5use serde_json::Value;
6use supercode::ChatMessage;
7use supercode::FrontendEvent;
8use supercode::Role;
9
10use crate::semantic_value_summary;
11
12/// Stable semantic category used by the terminal renderer.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum TranscriptKind {
15    User,
16    Assistant,
17    Shell,
18    FileRead,
19    FileWrite,
20    Patch,
21    Mcp,
22    Approval,
23    Subagent,
24    Scheduled,
25    Reduction,
26    Reasoning,
27    Notice,
28    Usage,
29    Generic,
30}
31
32/// Lifecycle state of one transcript cell.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum CellState {
35    Pending,
36    Complete,
37    Failed,
38}
39
40/// One renderer-ready transcript cell.
41///
42/// Unknown payloads are retained in full in `opaque_payload`; renderers must
43/// use a bounded preview and may expose the complete value through an explicit
44/// inspection action.
45#[derive(Clone, Debug, PartialEq)]
46pub struct TranscriptCell {
47    pub kind: TranscriptKind,
48    pub state: CellState,
49    pub title: String,
50    pub body: String,
51    pub event_kind: Option<String>,
52    pub opaque_payload: Option<Value>,
53}
54
55impl TranscriptCell {
56    fn complete(kind: TranscriptKind, title: impl Into<String>, body: impl Into<String>) -> Self {
57        Self {
58            kind,
59            state: CellState::Complete,
60            title: title.into(),
61            body: body.into(),
62            event_kind: None,
63            opaque_payload: None,
64        }
65    }
66
67    fn pending(kind: TranscriptKind, title: impl Into<String>, body: impl Into<String>) -> Self {
68        Self {
69            kind,
70            state: CellState::Pending,
71            title: title.into(),
72            body: body.into(),
73            event_kind: None,
74            opaque_payload: None,
75        }
76    }
77}
78
79/// Transcript state for a single frontend attachment.
80#[derive(Clone, Debug, Default)]
81pub struct TranscriptModel {
82    cells: Vec<TranscriptCell>,
83    tool_cells: HashMap<String, usize>,
84    streaming_cell: Option<usize>,
85    last_sequence: u64,
86}
87
88impl TranscriptModel {
89    /// Project the canonical history half of an atomic frontend attachment.
90    /// `history_cursor` marks every live event already represented by it.
91    pub fn from_history(history: &[ChatMessage], history_cursor: u64) -> Self {
92        let mut model = Self {
93            last_sequence: history_cursor,
94            ..Self::default()
95        };
96        for message in history {
97            model.push_history_message(message);
98        }
99        model
100    }
101
102    pub fn cells(&self) -> &[TranscriptCell] {
103        &self.cells
104    }
105
106    pub fn last_sequence(&self) -> u64 {
107        self.last_sequence
108    }
109
110    /// Complete the pending transcript cell for one exactly-once frontend
111    /// response while retaining both the original request payload and the
112    /// human-readable resolution.
113    pub fn resolve_frontend_request(&mut self, request_id: u64, resolution: &str) -> bool {
114        self.resolve_frontend_request_with_payload(request_id, resolution, None)
115    }
116
117    fn resolve_frontend_request_with_payload(
118        &mut self,
119        request_id: u64,
120        resolution: &str,
121        resolution_payload: Option<&Value>,
122    ) -> bool {
123        let Some(cell) =
124            self.cells.iter_mut().rev().find(|cell| {
125                request_id_from_opaque(cell.opaque_payload.as_ref()) == Some(request_id)
126            })
127        else {
128            return false;
129        };
130        let mut changed = false;
131        if cell.state == CellState::Pending {
132            if !cell.body.is_empty() {
133                cell.body.push('\n');
134            }
135            cell.body.push_str("Resolution: ");
136            cell.body.push_str(resolution);
137            cell.state = CellState::Complete;
138            changed = true;
139        }
140        if let Some(resolution_payload) = resolution_payload {
141            let request_payload = cell.opaque_payload.take().unwrap_or(Value::Null);
142            cell.opaque_payload = Some(serde_json::json!({
143                "request_event": request_payload,
144                "resolution_event": resolution_payload,
145            }));
146            changed = true;
147        }
148        changed
149    }
150
151    /// Apply one sequenced event. Returns `true` only when visible state
152    /// changed. Replayed or transport-duplicated sequence numbers are ignored.
153    pub fn apply_event(&mut self, event: &FrontendEvent) -> bool {
154        if event.sequence <= self.last_sequence {
155            return false;
156        }
157        self.last_sequence = event.sequence;
158
159        match event.kind.as_str() {
160            "user_message" => self.push_text_event(
161                TranscriptKind::User,
162                "You",
163                event.payload.get("text").and_then(Value::as_str),
164            ),
165            "text_delta" => self.append_assistant_delta(
166                event
167                    .payload
168                    .get("text")
169                    .and_then(Value::as_str)
170                    .unwrap_or_default(),
171            ),
172            "turn_completed" => self.finalize_assistant(),
173            "turn_started" | "turn_succeeded" => return false,
174            "turn_interrupted" => {
175                self.cells.push(TranscriptCell::complete(
176                    TranscriptKind::Notice,
177                    "Turn interrupted",
178                    "The active SDK turn was interrupted.",
179                ));
180            }
181            "turn_failed" => self.push_error(event),
182            "tool_call_started" => self.start_tool(event),
183            "tool_call_completed" => self.complete_tool(event),
184            "cache_warning" => self.push_text_event(
185                TranscriptKind::Notice,
186                "Cache warning",
187                event.payload.get("message").and_then(Value::as_str),
188            ),
189            "usage" => self.push_usage(event),
190            "background_output" => self.push_background_output(event),
191            "request" => self.push_request(event),
192            "request_resolved" => self.apply_request_resolution(event),
193            "scheduled_prompt_started"
194            | "scheduled_prompt_deferred"
195            | "scheduled_prompt_completed"
196            | "scheduler_error" => self.push_scheduled(event),
197            "stream_error" | "transport_error" | "watch_error" | "runtime_disconnected" => {
198                self.push_error(event)
199            }
200            "reasoning" | "thinking" => self.push_reasoning(event),
201            _ => self.push_generic(event),
202        }
203        true
204    }
205
206    fn push_history_message(&mut self, message: &ChatMessage) {
207        let text = message_text(message);
208        match message.role {
209            Role::System => self.cells.push(TranscriptCell::complete(
210                TranscriptKind::Notice,
211                "System",
212                text,
213            )),
214            Role::User => {
215                self.cells
216                    .push(TranscriptCell::complete(TranscriptKind::User, "You", text))
217            }
218            Role::Assistant => {
219                if !text.is_empty() {
220                    self.cells.push(TranscriptCell::complete(
221                        TranscriptKind::Assistant,
222                        "Assistant",
223                        text,
224                    ));
225                }
226                for call in message.tool_calls() {
227                    let kind = classify_tool(&call.function.name, &call.function.arguments, "");
228                    let index = self.cells.len();
229                    self.cells.push(TranscriptCell::pending(
230                        kind,
231                        tool_title(kind, &call.function.name),
232                        format_arguments(&call.function.arguments),
233                    ));
234                    self.tool_cells.insert(call.id.clone(), index);
235                }
236            }
237            Role::Tool => {
238                let id = message.tool_call_id.as_deref().unwrap_or_default();
239                if let Some(index) = self.tool_cells.remove(id) {
240                    let cell = &mut self.cells[index];
241                    if is_reduction(&cell.title, &cell.body, &text) {
242                        cell.kind = TranscriptKind::Reduction;
243                        cell.title = "Reduced context".into();
244                    }
245                    cell.body = text;
246                    cell.state = CellState::Complete;
247                } else {
248                    let name = message.name.as_deref().unwrap_or("tool");
249                    let kind = classify_tool(name, "", &text);
250                    self.cells
251                        .push(TranscriptCell::complete(kind, tool_title(kind, name), text));
252                }
253            }
254        }
255    }
256
257    fn push_text_event(&mut self, kind: TranscriptKind, title: &str, text: Option<&str>) {
258        self.cells.push(TranscriptCell::complete(
259            kind,
260            title,
261            text.unwrap_or_default(),
262        ));
263    }
264
265    fn append_assistant_delta(&mut self, delta: &str) {
266        let index = match self.streaming_cell {
267            Some(index) => index,
268            None => {
269                let index = self.cells.len();
270                self.cells.push(TranscriptCell::pending(
271                    TranscriptKind::Assistant,
272                    "Assistant",
273                    "",
274                ));
275                self.streaming_cell = Some(index);
276                index
277            }
278        };
279        self.cells[index].body.push_str(delta);
280    }
281
282    fn finalize_assistant(&mut self) {
283        if let Some(index) = self.streaming_cell.take() {
284            self.cells[index].state = CellState::Complete;
285        }
286    }
287
288    fn start_tool(&mut self, event: &FrontendEvent) {
289        let id = string_field(&event.payload, "id");
290        let name = string_field(&event.payload, "name");
291        let arguments = string_field(&event.payload, "arguments");
292        let kind = classify_tool(name, arguments, "");
293        let index = self.cells.len();
294        self.cells.push(TranscriptCell::pending(
295            kind,
296            tool_title(kind, name),
297            format_arguments(arguments),
298        ));
299        if !id.is_empty() {
300            self.tool_cells.insert(id.to_string(), index);
301        }
302    }
303
304    fn complete_tool(&mut self, event: &FrontendEvent) {
305        let id = string_field(&event.payload, "id");
306        let name = string_field(&event.payload, "name");
307        let output = string_field(&event.payload, "output");
308        let failed = event
309            .payload
310            .get("is_error")
311            .and_then(Value::as_bool)
312            .unwrap_or(false);
313        if let Some(index) = self.tool_cells.remove(id) {
314            let cell = &mut self.cells[index];
315            if is_reduction(name, &cell.body, output) {
316                cell.kind = TranscriptKind::Reduction;
317                cell.title = "Reduced context".into();
318            }
319            cell.body = output.to_string();
320            cell.state = if failed {
321                CellState::Failed
322            } else {
323                CellState::Complete
324            };
325            return;
326        }
327
328        let kind = classify_tool(name, "", output);
329        let mut cell = TranscriptCell::complete(kind, tool_title(kind, name), output);
330        if failed {
331            cell.state = CellState::Failed;
332        }
333        self.cells.push(cell);
334    }
335
336    fn push_usage(&mut self, event: &FrontendEvent) {
337        let prompt = event.payload.get("prompt_tokens").and_then(Value::as_u64);
338        let completion = event
339            .payload
340            .get("completion_tokens")
341            .and_then(Value::as_u64);
342        let total = event.payload.get("total_tokens").and_then(Value::as_u64);
343        let body = format!(
344            "prompt {} · completion {} · total {}",
345            token_value(prompt),
346            token_value(completion),
347            token_value(total)
348        );
349        self.cells.push(TranscriptCell::complete(
350            TranscriptKind::Usage,
351            "Usage",
352            body,
353        ));
354    }
355
356    fn push_background_output(&mut self, event: &FrontendEvent) {
357        let job = string_field(&event.payload, "job_id");
358        let mut body = string_field(&event.payload, "chunk").to_string();
359        if event
360            .payload
361            .get("truncated")
362            .and_then(Value::as_bool)
363            .unwrap_or(false)
364        {
365            body.push_str("\n[output truncated]");
366        }
367        self.cells.push(TranscriptCell::complete(
368            TranscriptKind::Scheduled,
369            format!("Background job {job}"),
370            body,
371        ));
372    }
373
374    fn push_request(&mut self, event: &FrontendEvent) {
375        let request = event.payload.get("request").unwrap_or(&event.payload);
376        let request_kind = request
377            .get("kind")
378            .and_then(Value::as_str)
379            .unwrap_or("request");
380        let kind = match request_kind {
381            "approval" => TranscriptKind::Approval,
382            "elicitation" => TranscriptKind::Mcp,
383            _ => TranscriptKind::Generic,
384        };
385        let payload = request.get("payload").unwrap_or(request);
386        let label = match kind {
387            TranscriptKind::Approval => "Approval request",
388            TranscriptKind::Mcp => "MCP input request",
389            _ => "Runtime input request",
390        };
391        self.cells.push(TranscriptCell {
392            kind,
393            state: CellState::Pending,
394            title: match kind {
395                TranscriptKind::Approval => "Approval requested".into(),
396                TranscriptKind::Mcp => "MCP input requested".into(),
397                _ => "Runtime input requested".into(),
398            },
399            body: semantic_value_summary(payload, label, 512),
400            event_kind: Some(event.kind.clone()),
401            opaque_payload: Some(event.payload.clone()),
402        });
403    }
404
405    fn apply_request_resolution(&mut self, event: &FrontendEvent) {
406        let Some(request_id) = event.payload.get("request_id").and_then(Value::as_u64) else {
407            return;
408        };
409        let resolution = event
410            .payload
411            .get("response")
412            .map(frontend_response_summary)
413            .unwrap_or_else(|| "resolved".into());
414        self.resolve_frontend_request_with_payload(request_id, &resolution, Some(&event.payload));
415    }
416
417    fn push_scheduled(&mut self, event: &FrontendEvent) {
418        let failed = event.kind == "scheduler_error";
419        self.cells.push(TranscriptCell {
420            kind: TranscriptKind::Scheduled,
421            state: if failed {
422                CellState::Failed
423            } else {
424                CellState::Complete
425            },
426            title: event.kind.replace('_', " "),
427            body: event_summary(&event.payload),
428            event_kind: Some(event.kind.clone()),
429            opaque_payload: Some(event.payload.clone()),
430        });
431    }
432
433    fn push_error(&mut self, event: &FrontendEvent) {
434        self.cells.push(TranscriptCell {
435            kind: TranscriptKind::Notice,
436            state: CellState::Failed,
437            title: format!("Error: {}", event.kind.replace('_', " ")),
438            body: event_summary(&event.payload),
439            event_kind: Some(event.kind.clone()),
440            opaque_payload: Some(event.payload.clone()),
441        });
442    }
443
444    fn push_reasoning(&mut self, event: &FrontendEvent) {
445        let body = event
446            .payload
447            .get("text")
448            .or_else(|| event.payload.get("summary"))
449            .and_then(Value::as_str)
450            .map(str::to_string)
451            .unwrap_or_else(|| compact_json(&event.payload));
452        self.cells.push(TranscriptCell::complete(
453            TranscriptKind::Reasoning,
454            "Reasoning",
455            body,
456        ));
457    }
458
459    fn push_generic(&mut self, event: &FrontendEvent) {
460        self.cells.push(TranscriptCell {
461            kind: TranscriptKind::Generic,
462            state: CellState::Complete,
463            title: format!("Event: {}", event.kind),
464            // Human terminals never dump protocol JSON. The lossless payload
465            // remains available in `opaque_payload` for a future inspector.
466            body: "This runtime event is not displayable by this frontend version.".into(),
467            event_kind: Some(event.kind.clone()),
468            opaque_payload: Some(event.payload.clone()),
469        });
470    }
471}
472
473fn message_text(message: &ChatMessage) -> String {
474    if let Some(content) = &message.content {
475        return content.clone();
476    }
477    message
478        .content_parts
479        .as_deref()
480        .unwrap_or_default()
481        .iter()
482        .filter_map(|part| match part.get("type").and_then(Value::as_str) {
483            Some("text") | Some("input_text") | Some("output_text") => {
484                part.get("text").and_then(Value::as_str).map(str::to_string)
485            }
486            Some("image") | Some("image_url") | Some("input_image") => Some("[image]".into()),
487            _ => None,
488        })
489        .collect::<Vec<_>>()
490        .join("\n")
491}
492
493fn classify_tool(name: &str, arguments: &str, output: &str) -> TranscriptKind {
494    let lower = name.to_ascii_lowercase();
495    if is_reduction(&lower, arguments, output) {
496        TranscriptKind::Reduction
497    } else if lower.contains("subagent")
498        || matches!(
499            lower.as_str(),
500            "task" | "spawn_agent" | "send_message" | "followup_task"
501        )
502    {
503        TranscriptKind::Subagent
504    } else if lower.contains("cron")
505        || lower.contains("schedule")
506        || lower.starts_with("background_")
507    {
508        TranscriptKind::Scheduled
509    } else if lower.starts_with("mcp") || lower.contains("__mcp__") {
510        TranscriptKind::Mcp
511    } else if lower == "apply_patch" || lower.contains("patch") {
512        TranscriptKind::Patch
513    } else if lower.contains("write") || lower.contains("edit") || lower.contains("notebook") {
514        TranscriptKind::FileWrite
515    } else if lower.contains("read")
516        || lower.contains("glob")
517        || lower.contains("search")
518        || lower.contains("list_dir")
519        || lower == "view_image"
520    {
521        TranscriptKind::FileRead
522    } else {
523        TranscriptKind::Shell
524    }
525}
526
527fn tool_title(kind: TranscriptKind, name: &str) -> String {
528    let action = match kind {
529        TranscriptKind::Shell => "Command",
530        TranscriptKind::FileRead => "Read",
531        TranscriptKind::FileWrite => "Write",
532        TranscriptKind::Patch => "Patch",
533        TranscriptKind::Mcp => "MCP",
534        TranscriptKind::Subagent => "Subagent",
535        TranscriptKind::Scheduled => "Scheduled",
536        TranscriptKind::Reduction => "Reduced context",
537        _ => "Tool",
538    };
539    if name.is_empty() || kind == TranscriptKind::Reduction {
540        action.into()
541    } else {
542        format!("{action}: {name}")
543    }
544}
545
546fn is_reduction(name: &str, arguments: &str, output: &str) -> bool {
547    let name = name.to_ascii_lowercase();
548    name.contains("reduction")
549        || name.contains("rehydrate")
550        || arguments.contains("[reduction:")
551        || output.contains("[reduction:")
552        || output.contains("<reduction ")
553}
554
555fn format_arguments(arguments: &str) -> String {
556    let Ok(value) = serde_json::from_str::<Value>(arguments) else {
557        return arguments.to_string();
558    };
559    compact_json(&value)
560}
561
562fn compact_json(value: &Value) -> String {
563    serde_json::to_string(value).unwrap_or_else(|_| "<unavailable>".into())
564}
565
566fn event_summary(payload: &Value) -> String {
567    semantic_value_summary(payload, "Runtime event", 512)
568}
569
570fn request_id_from_opaque(payload: Option<&Value>) -> Option<u64> {
571    let payload = payload?;
572    let request_event = payload.get("request_event").unwrap_or(payload);
573    request_event
574        .get("request")
575        .unwrap_or(request_event)
576        .get("id")
577        .and_then(Value::as_u64)
578}
579
580fn frontend_response_summary(response: &Value) -> String {
581    match response.get("kind").and_then(Value::as_str) {
582        Some("approval") => match response.get("decision").and_then(Value::as_str) {
583            Some("deny") => "denied".into(),
584            Some("allow") => "allowed once".into(),
585            Some("allow_for_session") => "allowed for session".into(),
586            Some(decision) => format!(
587                "approval: {}",
588                semantic_value_summary(&Value::String(decision.into()), "decision", 128)
589            ),
590            None => "approval resolved".into(),
591        },
592        Some("elicitation" | "other") => {
593            let action = response
594                .get("action")
595                .and_then(Value::as_str)
596                .unwrap_or("resolved");
597            if let Some(content) = response.get("content").filter(|content| !content.is_null()) {
598                format!(
599                    "{} · {}",
600                    semantic_value_summary(&Value::String(action.into()), "action", 64),
601                    semantic_value_summary(content, "content", 192)
602                )
603            } else {
604                semantic_value_summary(&Value::String(action.into()), "action", 128)
605            }
606        }
607        _ => semantic_value_summary(response, "Response", 256),
608    }
609}
610
611fn string_field<'a>(value: &'a Value, name: &str) -> &'a str {
612    value.get(name).and_then(Value::as_str).unwrap_or_default()
613}
614
615fn token_value(value: Option<u64>) -> String {
616    value.map_or_else(|| "?".into(), |value| value.to_string())
617}
618
619#[cfg(test)]
620mod tests {
621    use serde_json::json;
622    use supercode::FunctionCall;
623    use supercode::ToolCall;
624
625    use super::*;
626
627    fn event(sequence: u64, kind: &str, payload: Value) -> FrontendEvent {
628        FrontendEvent {
629            sequence,
630            kind: kind.into(),
631            payload,
632        }
633    }
634
635    #[test]
636    fn streaming_deltas_update_and_finalize_one_cell() {
637        let mut model = TranscriptModel::default();
638        assert!(model.apply_event(&event(
639            1,
640            "text_delta",
641            json!({"type":"text_delta", "text":"hello "})
642        )));
643        assert!(model.apply_event(&event(
644            2,
645            "text_delta",
646            json!({"type":"text_delta", "text":"world"})
647        )));
648        assert_eq!(model.cells.len(), 1);
649        assert_eq!(model.cells[0].body, "hello world");
650        assert_eq!(model.cells[0].state, CellState::Pending);
651
652        model.apply_event(&event(
653            3,
654            "turn_completed",
655            json!({"type":"turn_completed"}),
656        ));
657        assert_eq!(model.cells.len(), 1);
658        assert_eq!(model.cells[0].state, CellState::Complete);
659    }
660
661    #[test]
662    fn canonical_cursor_and_duplicate_sequences_never_repeat_content() {
663        let history = vec![
664            ChatMessage::user("prompt"),
665            ChatMessage::assistant("answer"),
666        ];
667        let mut model = TranscriptModel::from_history(&history, 8);
668        assert!(!model.apply_event(&event(
669            8,
670            "user_message",
671            json!({"type":"user_message", "text":"prompt"})
672        )));
673        assert!(model.apply_event(&event(
674            9,
675            "user_message",
676            json!({"type":"user_message", "text":"next"})
677        )));
678        assert!(!model.apply_event(&event(
679            9,
680            "user_message",
681            json!({"type":"user_message", "text":"next"})
682        )));
683        assert_eq!(
684            model
685                .cells
686                .iter()
687                .filter(|cell| cell.body == "prompt")
688                .count(),
689            1
690        );
691        assert_eq!(
692            model
693                .cells
694                .iter()
695                .filter(|cell| cell.body == "answer")
696                .count(),
697            1
698        );
699        assert_eq!(
700            model
701                .cells
702                .iter()
703                .filter(|cell| cell.body == "next")
704                .count(),
705            1
706        );
707    }
708
709    #[test]
710    fn history_pairs_tool_calls_and_results_into_one_cell() {
711        let assistant = ChatMessage {
712            role: Role::Assistant,
713            content: None,
714            content_parts: None,
715            tool_calls: Some(vec![ToolCall {
716                id: "call-1".into(),
717                kind: "function".into(),
718                function: FunctionCall {
719                    name: "read_file".into(),
720                    arguments: r#"{"path":"src/lib.rs"}"#.into(),
721                },
722            }]),
723            tool_call_id: None,
724            name: None,
725            metadata: Default::default(),
726        };
727        let model = TranscriptModel::from_history(
728            &[
729                assistant,
730                ChatMessage::tool_result("call-1", "read_file", "source"),
731            ],
732            4,
733        );
734        assert_eq!(model.cells.len(), 1);
735        assert_eq!(model.cells[0].kind, TranscriptKind::FileRead);
736        assert_eq!(model.cells[0].state, CellState::Complete);
737        assert_eq!(model.cells[0].body, "source");
738    }
739
740    #[test]
741    fn semantic_tool_families_render_without_provider_types() {
742        let cases = [
743            ("exec_command", TranscriptKind::Shell),
744            ("read_file", TranscriptKind::FileRead),
745            ("write_file", TranscriptKind::FileWrite),
746            ("apply_patch", TranscriptKind::Patch),
747            ("mcp__db__query", TranscriptKind::Mcp),
748            ("spawn_agent", TranscriptKind::Subagent),
749            ("cron_create", TranscriptKind::Scheduled),
750            ("expand_reduction", TranscriptKind::Reduction),
751        ];
752        let mut model = TranscriptModel::default();
753        for (offset, (name, expected)) in cases.into_iter().enumerate() {
754            let sequence = offset as u64 * 2 + 1;
755            model.apply_event(&event(
756                sequence,
757                "tool_call_started",
758                json!({"type":"tool_call_started", "id":format!("c{offset}"), "name":name, "arguments":"{}"}),
759            ));
760            assert_eq!(model.cells.last().unwrap().kind, expected);
761            model.apply_event(&event(
762                sequence + 1,
763                "tool_call_completed",
764                json!({"type":"tool_call_completed", "id":format!("c{offset}"), "name":name, "output":"ok", "is_error":false}),
765            ));
766            assert_eq!(model.cells.last().unwrap().state, CellState::Complete);
767        }
768    }
769
770    #[test]
771    fn failures_approvals_schedules_and_reduction_stubs_are_semantic() {
772        let mut model = TranscriptModel::default();
773        model.apply_event(&event(
774            1,
775            "tool_call_completed",
776            json!({"type":"tool_call_completed", "id":"bad", "name":"exec_command", "output":"denied", "is_error":true}),
777        ));
778        model.apply_event(&event(
779            2,
780            "request",
781            json!({"type":"request", "request":{"kind":"approval", "payload":{"tool":"bash"}}}),
782        ));
783        model.apply_event(&event(
784            3,
785            "scheduled_prompt_started",
786            json!({"type":"scheduled_prompt_started", "name":"nightly"}),
787        ));
788        model.apply_event(&event(
789            4,
790            "tool_call_completed",
791            json!({"type":"tool_call_completed", "id":"r", "name":"read_file", "output":"[reduction:r1 kind=tool_output]", "is_error":false}),
792        ));
793        model.apply_event(&event(
794            5,
795            "stream_error",
796            json!({"type":"stream_error", "message":"network lost"}),
797        ));
798
799        assert_eq!(model.cells[0].state, CellState::Failed);
800        assert_eq!(model.cells[1].kind, TranscriptKind::Approval);
801        assert_eq!(model.cells[2].kind, TranscriptKind::Scheduled);
802        assert_eq!(model.cells[3].kind, TranscriptKind::Reduction);
803        assert_eq!(model.cells[4].kind, TranscriptKind::Notice);
804        assert_eq!(model.cells[4].state, CellState::Failed);
805        assert_eq!(model.cells[4].body, "network lost");
806    }
807
808    #[test]
809    fn typed_frontend_resolution_completes_original_request_cell() {
810        let mut model = TranscriptModel::default();
811        model.apply_event(&event(
812            1,
813            "request",
814            json!({"type":"request", "request":{"id":42, "kind":"approval", "payload":{"tool":"bash"}}}),
815        ));
816        assert_eq!(model.cells()[0].state, CellState::Pending);
817        assert!(model.resolve_frontend_request(42, "allowed once"));
818        assert_eq!(model.cells()[0].state, CellState::Complete);
819        assert!(model.cells()[0].body.contains("Resolution: allowed once"));
820        assert!(model.cells()[0].opaque_payload.is_some());
821        assert!(!model.resolve_frontend_request(42, "denied"));
822    }
823
824    #[test]
825    fn unknown_event_is_human_safe_and_retains_full_opaque_payload() {
826        let payload =
827            json!({"type":"future_event", "large":"x".repeat(2000), "nested":{"kept":true}});
828        let mut model = TranscriptModel::default();
829        model.apply_event(&event(1, "future_event", payload.clone()));
830        let cell = &model.cells[0];
831        assert_eq!(cell.kind, TranscriptKind::Generic);
832        assert_eq!(cell.title, "Event: future_event");
833        assert!(!cell.body.contains("nested"));
834        assert!(!cell.body.starts_with('{'));
835        assert_eq!(cell.opaque_payload.as_ref(), Some(&payload));
836    }
837
838    #[test]
839    fn adversarial_request_is_bounded_and_exact_only_in_opaque_storage() {
840        let payload = json!({
841            "type":"request",
842            "request": {
843                "id": 77,
844                "kind":"approval",
845                "payload": {
846                    "nested": {"ansi": format!("\u{1b}]0;owned\u{7}{}", "x".repeat(10_000))}
847                }
848            }
849        });
850        let mut model = TranscriptModel::default();
851        model.apply_event(&event(1, "request", payload.clone()));
852        let cell = &model.cells()[0];
853        assert!(cell.body.chars().count() <= 512, "{}", cell.body.len());
854        assert!(!cell.body.contains("x".repeat(1_000).as_str()));
855        assert!(!cell.body.contains('\u{1b}'));
856        assert_eq!(cell.opaque_payload.as_ref(), Some(&payload));
857    }
858
859    #[test]
860    fn canonical_request_resolution_completes_pending_cell_for_all_observers() {
861        let mut model = TranscriptModel::default();
862        let request_event =
863            json!({"request":{"id":42,"kind":"approval","payload":{"tool":"bash"}}});
864        let resolution_event = json!({
865            "request_id":42,
866            "response":{"kind":"approval","request_id":42,"decision":"allow"}
867        });
868        model.apply_event(&event(1, "request", request_event.clone()));
869        model.apply_event(&event(2, "request_resolved", resolution_event.clone()));
870        assert_eq!(model.cells()[0].state, CellState::Complete);
871        assert!(model.cells()[0].body.contains("Resolution: allowed once"));
872        let retained = json!({
873            "request_event": request_event,
874            "resolution_event": resolution_event,
875        });
876        assert_eq!(model.cells()[0].opaque_payload.as_ref(), Some(&retained));
877    }
878
879    #[test]
880    fn canonical_resolution_enriches_an_optimistically_completed_request() {
881        let mut model = TranscriptModel::default();
882        model.apply_event(&event(
883            1,
884            "request",
885            json!({"request":{"id":7,"kind":"approval","payload":{"tool":"bash"}}}),
886        ));
887        assert!(model.resolve_frontend_request(7, "allowed once"));
888        model.apply_event(&event(
889            2,
890            "request_resolved",
891            json!({"request_id":7,"response":{"kind":"approval","request_id":7,"decision":"allow"}}),
892        ));
893        let cell = &model.cells()[0];
894        assert_eq!(cell.body.matches("Resolution:").count(), 1);
895        assert_eq!(
896            cell.opaque_payload.as_ref().unwrap()["resolution_event"]["response"]["decision"],
897            "allow"
898        );
899    }
900}