use crate::session::{TurnBlock, TurnSnapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReasoningVisibility { Hidden, Collapsed, Expanded, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolVisibility { Hidden, Compact, Detailed, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PresentationPolicy { pub live: bool, pub reasoning: ReasoningVisibility, pub tools: ToolVisibility, } impl PresentationPolicy { pub const fn interactive() -> Self { Self { live: true, reasoning: ReasoningVisibility::Collapsed, tools: ToolVisibility::Detailed, } } pub const fn external(live: bool) -> Self { Self { live, reasoning: ReasoningVisibility::Hidden, tools: ToolVisibility::Compact, } } pub const fn unattended() -> Self { Self { live: false, reasoning: ReasoningVisibility::Hidden, tools: ToolVisibility::Hidden, } } } /// Produce the immutable view that is allowed to leave the Gateway core. /// /// Presentation filtering deliberately clones the snapshot. Conversation /// history and the authoritative TurnController state remain untouched. pub fn project_snapshot(snapshot: &TurnSnapshot, policy: PresentationPolicy) -> TurnSnapshot { let mut projected = snapshot.clone(); projected.blocks.retain_mut(|block| match block { TurnBlock::Reasoning { .. } => policy.reasoning != ReasoningVisibility::Hidden, TurnBlock::Assistant { .. } => true, TurnBlock::Tool { arguments, preview, .. } => match policy.tools { ToolVisibility::Hidden => false, ToolVisibility::Compact => { *arguments = serde_json::Value::Null; *preview = None; true } ToolVisibility::Detailed => true, }, }); projected } #[cfg(test)] mod tests { use super::*; use crate::session::{BlockId, ToolStatus, TurnId, TurnPhase, TurnState, TurnStatus}; fn snapshot() -> TurnSnapshot { TurnState { id: TurnId("turn".into()), session_id: "session".into(), message_id: "message".into(), revision: 3, status: TurnStatus::Running, phase: TurnPhase::Acting, blocks: vec![ TurnBlock::Reasoning { id: BlockId("reasoning".into()), iteration: 0, text: "private chain".into(), }, TurnBlock::Assistant { id: BlockId("text".into()), iteration: 0, text: "visible".into(), }, TurnBlock::Tool { id: "tool".into(), iteration: 0, name: "bash".into(), arguments: serde_json::json!({"token": "secret"}), status: ToolStatus::Completed, preview: Some("sensitive output".into()), }, ], usage: None, error: None, } } #[test] fn external_projection_removes_reasoning_and_tool_details_without_mutating_source() { let source = snapshot(); let projected = project_snapshot(&source, PresentationPolicy::external(true)); assert_eq!(projected.blocks.len(), 2); assert!(matches!(projected.blocks[0], TurnBlock::Assistant { .. })); assert!(matches!( &projected.blocks[1], TurnBlock::Tool { arguments: serde_json::Value::Null, preview: None, .. } )); assert_eq!(source.blocks.len(), 3); assert!(matches!(source.blocks[0], TurnBlock::Reasoning { .. })); } #[test] fn unattended_projection_keeps_only_assistant_blocks() { let projected = project_snapshot(&snapshot(), PresentationPolicy::unattended()); assert_eq!(projected.blocks.len(), 1); assert!(matches!(projected.blocks[0], TurnBlock::Assistant { .. })); } }