1use 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum CellState {
35 Pending,
36 Complete,
37 Failed,
38}
39
40#[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#[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 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 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 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 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}