Skip to main content

talos_core/
session.rs

1//! Session protocol types for the AppServerSession seam (ADR-005).
2//!
3//! SQ (Submission Queue): bounded `mpsc::Sender<SessionOp>` (cap=512) for commands TO the session actor.
4//! EQ (Event Queue): unbounded `mpsc::UnboundedSender<SessionEvent>` for events FROM the session actor.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10
11use crate::message::AgentEvent;
12use crate::message::Message;
13
14/// Commands sent to the session actor via the bounded SQ.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum SessionOp {
18    /// Submit a user message for the agent to process.
19    Submit { message: String },
20    /// Build a provider request preview for diagnostics without calling the provider.
21    PreviewRequest { message: String },
22    /// Replace the model-visible activated Skill context.
23    ///
24    /// The CLI/runtime layer is responsible for validating paths and budgets
25    /// before sending this operation. The session actor only updates prompt
26    /// state and invalidates the agent's stable prompt prefix.
27    SetSkillContext {
28        /// Active Skill name, or `None` to clear activation.
29        name: Option<String>,
30        /// Bounded Skill body/reference content, or `None` to clear activation.
31        content: Option<String>,
32    },
33    /// Interrupt the current turn.
34    Interrupt,
35    /// Shut down the session actor.
36    Shutdown,
37}
38
39/// Events emitted by the session actor on the unbounded EQ.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(tag = "type", rename_all = "snake_case")]
42#[non_exhaustive]
43pub enum SessionEvent {
44    /// An agent event (text delta, tool call, etc.) from the current turn.
45    AgentEvent {
46        /// The inner streaming agent event.
47        event: AgentEvent,
48    },
49    /// A tool requires user approval. The consumer must respond via the approval channel.
50    ApprovalRequired {
51        tool_name: String,
52        arguments: String,
53        call_id: String,
54    },
55    /// A new turn has started.
56    TurnStarted { turn_id: String },
57    /// A turn has completed.
58    TurnCompleted {
59        turn_id: String,
60        status: TurnCompletionStatus,
61    },
62    /// A session-level error.
63    Error { message: String },
64}
65
66/// Status of a completed turn.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "status", rename_all = "snake_case")]
69pub enum TurnCompletionStatus {
70    /// Turn completed normally.
71    Success {
72        /// The final assistant response text.
73        #[serde(default)]
74        final_text: String,
75        /// Messages produced during this turn, in chronological order.
76        /// This is the authoritative sequence for persistence/replay.
77        #[serde(default, skip_serializing_if = "Vec::is_empty")]
78        new_messages: Vec<crate::message::Message>,
79    },
80    /// Turn was cancelled by user interrupt.
81    Cancelled,
82    /// Turn ended with an error.
83    Error {
84        /// Error message.
85        message: String,
86    },
87}
88
89/// Handle returned to the UI layer for interacting with a session.
90///
91/// The UI sends commands via `sq_tx` and receives events via `eq_rx`.
92pub struct SessionHandle {
93    /// Bounded submission queue sender (cap=512).
94    pub sq_tx: mpsc::Sender<SessionOp>,
95    /// Unbounded event queue receiver.
96    pub eq_rx: mpsc::UnboundedReceiver<SessionEvent>,
97}
98
99/// Configuration for creating a session actor.
100///
101/// Captures CLI-layer decisions that the session actor needs.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct SessionConfig {
104    /// Product-neutral runtime policy for the session actor.
105    #[serde(default)]
106    pub runtime_policy: RuntimePolicy,
107    /// Workspace root path for file operations.
108    pub workspace_root: PathBuf,
109    /// Prior conversation messages to include in the first turn.
110    #[serde(default)]
111    pub initial_history: Vec<Message>,
112    /// Model context token limit for compaction triggering.
113    #[serde(default = "default_model_context_limit")]
114    pub model_context_limit: u32,
115}
116
117/// Product-neutral policy for session runtime behavior.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(default)]
120pub struct RuntimePolicy {
121    /// How the runtime should behave when a tool requests approval and no
122    /// caller-specific approval handler handles it first.
123    pub approval_mode: ApprovalMode,
124}
125
126impl RuntimePolicy {
127    /// Interactive policy for UI-owned sessions.
128    #[must_use]
129    pub fn interactive() -> Self {
130        Self {
131            approval_mode: ApprovalMode::Interactive,
132        }
133    }
134
135    /// Headless policy for non-interactive sessions that cannot ask a user.
136    #[must_use]
137    pub fn headless_deny() -> Self {
138        Self {
139            approval_mode: ApprovalMode::HeadlessDeny,
140        }
141    }
142}
143
144impl Default for RuntimePolicy {
145    fn default() -> Self {
146        Self::interactive()
147    }
148}
149
150/// Approval behavior for a session runtime.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ApprovalMode {
154    /// Approval prompts may be surfaced by the product/UI layer.
155    #[default]
156    Interactive,
157    /// Approval requests are denied because no user approval channel exists.
158    HeadlessDeny,
159}
160
161fn default_model_context_limit() -> u32 {
162    128_000
163}
164
165#[cfg(test)]
166#[allow(warnings)]
167#[allow(warnings)]
168#[allow(warnings)]
169#[allow(warnings)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn session_op_serde_roundtrip() {
175        let ops = vec![
176            SessionOp::Submit {
177                message: "hello".into(),
178            },
179            SessionOp::PreviewRequest {
180                message: "diagnostic".into(),
181            },
182            SessionOp::Interrupt,
183            SessionOp::Shutdown,
184        ];
185        for op in &ops {
186            let json = serde_json::to_string(op).unwrap();
187            let back: SessionOp = serde_json::from_str(&json).unwrap();
188            assert_eq!(
189                serde_json::to_value(op).unwrap(),
190                serde_json::to_value(&back).unwrap()
191            );
192        }
193    }
194
195    #[test]
196    fn session_event_serde_roundtrip() {
197        let events = vec![
198            SessionEvent::AgentEvent {
199                event: AgentEvent::TextDelta {
200                    delta: "hello".into(),
201                },
202            },
203            SessionEvent::ApprovalRequired {
204                tool_name: "write".into(),
205                arguments: "{}".into(),
206                call_id: "call_1".into(),
207            },
208            SessionEvent::TurnStarted {
209                turn_id: "1".into(),
210            },
211            SessionEvent::TurnCompleted {
212                turn_id: "1".into(),
213                status: TurnCompletionStatus::Success {
214                    final_text: String::new(),
215                    new_messages: vec![],
216                },
217            },
218            SessionEvent::TurnCompleted {
219                turn_id: "2".into(),
220                status: TurnCompletionStatus::Cancelled,
221            },
222            SessionEvent::TurnCompleted {
223                turn_id: "3".into(),
224                status: TurnCompletionStatus::Error {
225                    message: "boom".into(),
226                },
227            },
228            SessionEvent::Error {
229                message: "fail".into(),
230            },
231        ];
232        for event in &events {
233            let json = serde_json::to_string(event).unwrap();
234            let back: SessionEvent = serde_json::from_str(&json).unwrap();
235            assert_eq!(
236                serde_json::to_value(event).unwrap(),
237                serde_json::to_value(&back).unwrap()
238            );
239        }
240    }
241
242    #[test]
243    fn session_config_serde_roundtrip() {
244        let config = SessionConfig {
245            runtime_policy: RuntimePolicy::headless_deny(),
246            workspace_root: PathBuf::from("/tmp/test"),
247            initial_history: vec![],
248            model_context_limit: 128_000,
249        };
250        let json = serde_json::to_string(&config).unwrap();
251        let back: SessionConfig = serde_json::from_str(&json).unwrap();
252        assert_eq!(config.runtime_policy, back.runtime_policy);
253        assert_eq!(config.workspace_root, back.workspace_root);
254        assert_eq!(config.initial_history, back.initial_history);
255        assert_eq!(config.model_context_limit, back.model_context_limit);
256    }
257
258    #[test]
259    fn session_config_defaults_to_interactive_runtime_policy() {
260        let json = r#"{
261            "workspace_root": "/tmp/test",
262            "initial_history": [],
263            "model_context_limit": 128000
264        }"#;
265        let back: SessionConfig = serde_json::from_str(json).unwrap();
266        assert_eq!(back.runtime_policy, RuntimePolicy::interactive());
267    }
268}