1use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10
11use crate::message::{AgentEvent, Message};
12pub use crate::submission::{
13 MAX_PENDING_SUBMISSION_BYTES, MAX_PENDING_SUBMISSIONS, MAX_STEERING_QUEUE_BYTES,
14 MAX_STEERING_QUEUE_IMAGE_BYTES, MAX_STEERING_QUEUE_IMAGES, MAX_STEERING_QUEUE_ITEMS,
15 MAX_SUBMISSION_BATCH_BYTES, MAX_SUBMISSION_BATCH_ITEMS, MAX_SUBMISSION_IMAGE_BYTES,
16 MAX_SUBMISSION_IMAGE_COUNT, MAX_SUBMISSION_ITEM_BYTES, MAX_SUBMISSION_TOTAL_IMAGE_BYTES,
17 PendingSubmissionState, StructuredSubmission, SubmissionItem, SubmissionKind,
18 SubmissionReceipt, SubmissionReceiptDisposition, SubmissionRejectionReason, SubmissionSource,
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum SessionOp {
25 Submit {
26 message: String,
27 },
28 SubmitMultimodal {
29 text: String,
30 attachments: Vec<crate::message::ContentPart>,
31 },
32 PreviewRequest {
33 message: String,
34 },
35 SubmitStructured {
36 submission: StructuredSubmission,
37 },
38 SubmitStructuredTracked {
39 submission: StructuredSubmission,
40 #[serde(skip)]
41 receipt_tx: Option<mpsc::UnboundedSender<SubmissionReceipt>>,
42 },
43 ReconcileStructured {
45 submission: StructuredSubmission,
46 },
47 ReconcileStructuredTracked {
49 submission: StructuredSubmission,
50 #[serde(skip)]
51 receipt_tx: Option<mpsc::UnboundedSender<SubmissionReceipt>>,
52 },
53 SubmitStructuredReconcile {
55 submission: StructuredSubmission,
56 },
57 SubmitStructuredReconcileTracked {
59 submission: StructuredSubmission,
60 #[serde(skip)]
61 receipt_tx: Option<mpsc::UnboundedSender<SubmissionReceipt>>,
62 },
63 SetSkillContext {
64 name: Option<String>,
65 content: Option<String>,
66 },
67 Interrupt,
69 InterruptTurn {
71 session_generation: u64,
72 turn_id: String,
73 },
74 CancelPausedSubmission {
78 session_generation: u64,
79 submission_id: String,
80 },
81 Shutdown,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(tag = "type", rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum SessionEvent {
88 SubmissionQueued {
89 session_id: String,
90 submission_id: String,
91 sender_generation: u64,
92 source: SubmissionSource,
93 item_count: usize,
94 total_text_bytes: usize,
95 },
96 SubmissionStarted {
97 session_id: String,
98 submission_id: String,
99 sender_generation: u64,
100 turn_id: String,
101 },
102 StructuredSubmissionStarted {
106 session_id: String,
107 session_generation: u64,
108 submission: StructuredSubmission,
109 receipt_id: String,
110 turn_id: String,
111 },
112 SubmissionRejected {
113 session_id: String,
114 submission_id: String,
115 sender_generation: u64,
116 reason: SubmissionRejectionReason,
117 },
118 SubmissionPaused {
119 session_id: String,
120 session_generation: u64,
121 submission_id: String,
122 receipt_id: String,
123 reason: SubmissionRejectionReason,
124 },
125 SubmissionResolved {
128 session_id: String,
129 session_generation: u64,
130 submission_id: String,
131 receipt_id: String,
132 state: PendingSubmissionState,
133 },
134 SubmissionReceipt {
135 session_id: String,
136 session_generation: u64,
137 submission_id: String,
138 reservation_id: String,
139 receipt_id: String,
140 source: SubmissionSource,
141 item_count: usize,
142 total_text_bytes: usize,
143 disposition: SubmissionReceiptDisposition,
144 },
145 StructuredTurnEvent {
146 session_id: String,
147 session_generation: u64,
148 source: SubmissionSource,
149 submission_id: String,
150 receipt_id: String,
151 turn_id: String,
152 sequence: u64,
153 payload: TurnEventPayload,
154 },
155 EntriesCommitted {
156 session_id: String,
157 turn_id: String,
158 entry_ids: Vec<String>,
159 },
160 TurnEvent {
161 session_id: String,
162 turn_id: String,
163 sequence: u64,
164 payload: TurnEventPayload,
165 },
166 AgentEvent {
167 event: AgentEvent,
168 },
169 ApprovalRequired {
170 tool_name: String,
171 arguments: String,
172 call_id: String,
173 },
174 TurnStarted {
175 turn_id: String,
176 },
177 TurnCompleted {
178 turn_id: String,
179 status: TurnCompletionStatus,
180 },
181 Error {
182 message: String,
183 },
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(tag = "kind", rename_all = "snake_case")]
188#[non_exhaustive]
189pub enum TurnEventPayload {
190 Started,
191 Progress { event: AgentEvent },
192 Completed { status: TurnCompletionStatus },
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(tag = "status", rename_all = "snake_case")]
197pub enum TurnCompletionStatus {
198 Success {
199 #[serde(default)]
200 final_text: String,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 new_messages: Vec<crate::message::Message>,
203 },
204 Cancelled,
205 Error {
206 message: String,
207 },
208}
209
210pub struct SessionHandle {
211 pub sq_tx: mpsc::Sender<SessionOp>,
212 pub eq_rx: mpsc::UnboundedReceiver<SessionEvent>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct SessionConfig {
217 #[serde(default)]
218 pub runtime_policy: RuntimePolicy,
219 pub workspace_root: PathBuf,
220 #[serde(default)]
221 pub initial_history: Vec<Message>,
222 #[serde(default = "default_model_context_limit")]
223 pub model_context_limit: u32,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(default)]
228pub struct RuntimePolicy {
229 pub approval_mode: ApprovalMode,
230}
231
232impl RuntimePolicy {
233 #[must_use]
234 pub fn interactive() -> Self {
235 Self {
236 approval_mode: ApprovalMode::Interactive,
237 }
238 }
239
240 #[must_use]
241 pub fn headless_deny() -> Self {
242 Self {
243 approval_mode: ApprovalMode::HeadlessDeny,
244 }
245 }
246}
247
248impl Default for RuntimePolicy {
249 fn default() -> Self {
250 Self::interactive()
251 }
252}
253
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ApprovalMode {
257 #[default]
258 Interactive,
259 HeadlessDeny,
260}
261
262fn default_model_context_limit() -> u32 {
263 128_000
264}
265
266#[cfg(test)]
267#[allow(warnings)]
268mod tests {
269 use super::*;
270
271 fn structured_submission() -> StructuredSubmission {
272 StructuredSubmission {
273 id: "batch_1".into(),
274 source: SubmissionSource::User,
275 sender_generation: 7,
276 items: vec![SubmissionItem {
277 id: "item_1".into(),
278 enqueue_sequence: 1,
279 kind: SubmissionKind::UserTurn,
280 text: "structured".into(),
281 attachments: Vec::new(),
282 }],
283 }
284 }
285
286 #[test]
287 fn session_op_serde_roundtrip() {
288 let submission = structured_submission();
289 let ops = vec![
290 SessionOp::Submit {
291 message: "hello".into(),
292 },
293 SessionOp::PreviewRequest {
294 message: "diagnostic".into(),
295 },
296 SessionOp::SubmitStructured {
297 submission: submission.clone(),
298 },
299 SessionOp::SubmitStructuredTracked {
300 submission: submission.clone(),
301 receipt_tx: None,
302 },
303 SessionOp::ReconcileStructured {
304 submission: submission.clone(),
305 },
306 SessionOp::ReconcileStructuredTracked {
307 submission: submission.clone(),
308 receipt_tx: None,
309 },
310 SessionOp::SubmitStructuredReconcile {
311 submission: submission.clone(),
312 },
313 SessionOp::SubmitStructuredReconcileTracked {
314 submission,
315 receipt_tx: None,
316 },
317 SessionOp::Interrupt,
318 SessionOp::InterruptTurn {
319 session_generation: 7,
320 turn_id: "turn_7".into(),
321 },
322 SessionOp::CancelPausedSubmission {
323 session_generation: 7,
324 submission_id: "batch_1".into(),
325 },
326 SessionOp::Shutdown,
327 ];
328 for op in &ops {
329 let json = serde_json::to_string(op).expect("operation should succeed");
330 let back: SessionOp = serde_json::from_str(&json).expect("operation should succeed");
331 assert_eq!(
332 serde_json::to_value(op).expect("operation should succeed"),
333 serde_json::to_value(&back).expect("operation should succeed")
334 );
335 }
336 }
337
338 #[test]
339 fn session_event_serde_roundtrip() {
340 let submission = structured_submission();
341 let events = vec![
342 SessionEvent::SubmissionQueued {
343 session_id: "session_1".into(),
344 submission_id: "batch_1".into(),
345 sender_generation: 7,
346 source: SubmissionSource::User,
347 item_count: 1,
348 total_text_bytes: 10,
349 },
350 SessionEvent::SubmissionPaused {
351 session_id: "session_1".into(),
352 session_generation: 7,
353 submission_id: "batch_1".into(),
354 receipt_id: "receipt_1".into(),
355 reason: SubmissionRejectionReason::ContextBudgetExceeded,
356 },
357 SessionEvent::SubmissionResolved {
358 session_id: "session_1".into(),
359 session_generation: 7,
360 submission_id: "batch_1".into(),
361 receipt_id: "receipt_1".into(),
362 state: PendingSubmissionState::TerminalCancelled,
363 },
364 SessionEvent::SubmissionReceipt {
365 session_id: "session_1".into(),
366 session_generation: 7,
367 submission_id: "batch_1".into(),
368 reservation_id: "reservation:batch_1".into(),
369 receipt_id: "receipt_1".into(),
370 source: SubmissionSource::User,
371 item_count: 1,
372 total_text_bytes: 10,
373 disposition: SubmissionReceiptDisposition::AlreadyAccepted {
374 state: PendingSubmissionState::AcceptedPending,
375 turn_id: None,
376 },
377 },
378 SessionEvent::StructuredSubmissionStarted {
379 session_id: "session_1".into(),
380 session_generation: 7,
381 submission: submission.clone(),
382 receipt_id: "receipt_1".into(),
383 turn_id: "turn_1".into(),
384 },
385 SessionEvent::StructuredTurnEvent {
386 session_id: "session_1".into(),
387 session_generation: 7,
388 source: SubmissionSource::User,
389 submission_id: "batch_1".into(),
390 receipt_id: "receipt_1".into(),
391 turn_id: "turn_1".into(),
392 sequence: 0,
393 payload: TurnEventPayload::Started,
394 },
395 SessionEvent::TurnEvent {
396 session_id: "session_1".into(),
397 turn_id: "turn_1".into(),
398 sequence: 1,
399 payload: TurnEventPayload::Progress {
400 event: AgentEvent::TextDelta {
401 delta: "hello".into(),
402 },
403 },
404 },
405 SessionEvent::TurnCompleted {
406 turn_id: "2".into(),
407 status: TurnCompletionStatus::Cancelled,
408 },
409 SessionEvent::Error {
410 message: "fail".into(),
411 },
412 ];
413 for event in &events {
414 let json = serde_json::to_string(event).expect("operation should succeed");
415 let back: SessionEvent = serde_json::from_str(&json).expect("operation should succeed");
416 assert_eq!(
417 serde_json::to_value(event).expect("operation should succeed"),
418 serde_json::to_value(&back).expect("operation should succeed")
419 );
420 }
421 }
422
423 #[test]
424 fn session_config_serde_roundtrip() {
425 let config = SessionConfig {
426 runtime_policy: RuntimePolicy::headless_deny(),
427 workspace_root: PathBuf::from("/tmp/test"),
428 initial_history: vec![],
429 model_context_limit: 128_000,
430 };
431 let json = serde_json::to_string(&config).expect("operation should succeed");
432 let back: SessionConfig = serde_json::from_str(&json).expect("operation should succeed");
433 assert_eq!(config.runtime_policy, back.runtime_policy);
434 assert_eq!(config.workspace_root, back.workspace_root);
435 assert_eq!(config.initial_history, back.initial_history);
436 assert_eq!(config.model_context_limit, back.model_context_limit);
437 }
438}