1use crate::{CheckpointKind, PluginMessage, TurnCause, TurnInput};
2
3#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4#[serde(tag = "scope", rename_all = "snake_case")]
5pub enum TurnInputIngress {
6 ActiveTurn {
7 turn_id: String,
8 #[serde(default)]
9 min_boundary: TurnInputCheckpointBoundary,
10 },
11 NextTurn,
12}
13
14impl TurnInputIngress {
15 pub fn active_turn(
16 turn_id: impl Into<String>,
17 min_boundary: TurnInputCheckpointBoundary,
18 ) -> Self {
19 Self::ActiveTurn {
20 turn_id: turn_id.into(),
21 min_boundary,
22 }
23 }
24
25 pub fn next_turn() -> Self {
26 Self::NextTurn
27 }
28
29 pub fn active_turn_id(&self) -> Option<&str> {
30 match self {
31 Self::ActiveTurn { turn_id, .. } => Some(turn_id),
32 Self::NextTurn => None,
33 }
34 }
35
36 pub fn admits_checkpoint(&self, checkpoint: CheckpointKind) -> bool {
37 match self {
38 Self::ActiveTurn { min_boundary, .. } => min_boundary.admits(checkpoint),
39 Self::NextTurn => false,
40 }
41 }
42}
43
44#[derive(
45 Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
46)]
47#[serde(rename_all = "snake_case")]
48pub enum TurnInputCheckpointBoundary {
49 #[default]
50 AfterWork,
51 BeforeCompletion,
52}
53
54impl TurnInputCheckpointBoundary {
55 pub fn admits(self, checkpoint: CheckpointKind) -> bool {
56 match self {
57 Self::AfterWork => true,
58 Self::BeforeCompletion => checkpoint == CheckpointKind::BeforeCompletion,
59 }
60 }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum TurnInputState {
66 PendingActive,
67 DeferredNextTurn,
68 Accepted,
69 Cancelled,
70 Completed,
71}
72
73impl TurnInputState {
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::PendingActive => "pending_active",
77 Self::DeferredNextTurn => "deferred_next_turn",
78 Self::Accepted => "accepted",
79 Self::Cancelled => "cancelled",
80 Self::Completed => "completed",
81 }
82 }
83
84 pub fn from_wire_str(value: &str) -> Option<Self> {
85 match value {
86 "pending_active" => Some(Self::PendingActive),
87 "deferred_next_turn" => Some(Self::DeferredNextTurn),
88 "accepted" => Some(Self::Accepted),
89 "cancelled" => Some(Self::Cancelled),
90 "completed" => Some(Self::Completed),
91 _ => None,
92 }
93 }
94
95 pub fn is_next_turn_pending(self) -> bool {
96 matches!(self, Self::DeferredNextTurn)
97 }
98}
99
100#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
101pub struct PendingTurnInputDraft {
102 pub session_id: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub input_id: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub source_key: Option<String>,
107 pub ingress: TurnInputIngress,
108 pub input: TurnInput,
109}
110
111impl PendingTurnInputDraft {
112 pub fn new(session_id: impl Into<String>, ingress: TurnInputIngress, input: TurnInput) -> Self {
113 Self {
114 session_id: session_id.into(),
115 input_id: None,
116 source_key: None,
117 ingress,
118 input,
119 }
120 }
121
122 pub fn with_input_id(mut self, input_id: impl Into<String>) -> Self {
123 self.input_id = Some(input_id.into());
124 self
125 }
126
127 pub fn with_source_key(mut self, source_key: impl Into<String>) -> Self {
128 self.source_key = Some(source_key.into());
129 self
130 }
131
132 pub fn submitted_content_matches(
133 &self,
134 existing: &PendingTurnInput,
135 ) -> Result<bool, serde_json::Error> {
136 Ok(self.ingress == existing.ingress
137 && serde_json::to_value(&self.input)? == serde_json::to_value(&existing.input)?)
138 }
139}
140
141#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
142pub struct PendingTurnInput {
143 pub input_id: String,
144 pub session_id: String,
145 pub enqueue_seq: u64,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub source_key: Option<String>,
148 pub ingress: TurnInputIngress,
149 pub state: TurnInputState,
150 pub enqueued_at_ms: u64,
151 pub input: TurnInput,
152}
153
154#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
160pub struct TurnInputAcceptanceReceipt {
161 pub input_id: String,
162 pub session_id: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub source_key: Option<String>,
165 pub ingress: TurnInputIngress,
166}
167
168#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175pub struct TurnInputApplication {
176 pub input_id: String,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub source_key: Option<String>,
179 pub turn_id: String,
180 pub committed_message_id: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub checkpoint: Option<CheckpointKind>,
185}
186
187impl From<&PendingTurnInput> for TurnInputAcceptanceReceipt {
188 fn from(input: &PendingTurnInput) -> Self {
189 Self {
190 input_id: input.input_id.clone(),
191 session_id: input.session_id.clone(),
192 source_key: input.source_key.clone(),
193 ingress: input.ingress.clone(),
194 }
195 }
196}
197
198impl PendingTurnInput {
199 pub fn source_or_id(&self) -> &str {
200 self.source_key.as_deref().unwrap_or(&self.input_id)
201 }
202
203 pub fn accepted_input(&self) -> Option<crate::AcceptedInjectedTurnInput> {
204 plugin_message_from_turn_input(&self.input).map(|message| {
205 crate::AcceptedInjectedTurnInput {
206 id: self
207 .source_key
208 .as_deref()
209 .map(source_key_display_id)
210 .or_else(|| Some(self.input_id.clone())),
211 message,
212 }
213 })
214 }
215}
216
217#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
219pub enum PendingTurnInputCancelTarget {
220 InputId(String),
221 SourceKey(String),
222}
223
224impl PendingTurnInputCancelTarget {
225 pub fn input_id(input_id: impl Into<String>) -> Self {
226 Self::InputId(input_id.into())
227 }
228
229 pub fn source_key(source_key: impl Into<String>) -> Self {
230 Self::SourceKey(source_key.into())
231 }
232}
233
234#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
235pub struct PendingTurnInputClaimDiagnostics {
236 pub state: TurnInputState,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub claim_id: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub claim_owner: Option<crate::LeaseOwnerIdentity>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub claim_session_lease_generation: Option<u64>,
245 pub claim_fencing_token: u64,
246}
247
248#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
249#[serde(tag = "outcome", content = "data", rename_all = "snake_case")]
250pub enum PendingTurnInputCancelOutcome {
251 Cancelled(PendingTurnInput),
252 AlreadyClaimed {
253 input: PendingTurnInput,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 claim: Option<PendingTurnInputClaimDiagnostics>,
256 },
257 AlreadyCompleted(PendingTurnInput),
258 AlreadyCancelled(PendingTurnInput),
259 NotFound,
260}
261
262impl PendingTurnInputCancelOutcome {
263 pub fn is_cancelled(&self) -> bool {
264 matches!(self, Self::Cancelled(_))
265 }
266
267 pub fn input(&self) -> Option<&PendingTurnInput> {
268 match self {
269 Self::Cancelled(input)
270 | Self::AlreadyClaimed { input, .. }
271 | Self::AlreadyCompleted(input)
272 | Self::AlreadyCancelled(input) => Some(input),
273 Self::NotFound => None,
274 }
275 }
276}
277
278#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
279pub struct PendingTurnInputCancelResult {
280 pub target: PendingTurnInputCancelTarget,
281 pub outcome: PendingTurnInputCancelOutcome,
282}
283
284#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
285#[serde(tag = "outcome", content = "data", rename_all = "snake_case")]
286pub enum PendingTurnInputSuffixCancelOutcome {
287 AnchorNotFound {
288 anchor: PendingTurnInputCancelTarget,
289 },
290 Outcomes {
291 anchor: PendingTurnInputCancelTarget,
292 outcomes: Vec<PendingTurnInputCancelOutcome>,
293 },
294}
295
296#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
297#[serde(tag = "kind", rename_all = "snake_case")]
298pub enum TurnInputClaimMode {
299 ActiveTurn {
300 turn_id: String,
301 checkpoint: CheckpointKind,
302 },
303 NextTurn,
304}
305
306#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
307pub struct TurnInputCompletion {
308 pub session_id: String,
309 pub claim_id: String,
310 pub lease_token: String,
311 pub input_ids: Vec<String>,
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub applications: Vec<TurnInputApplication>,
314}
315
316#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
317pub struct TurnInputClaim {
318 pub session_id: String,
319 pub claim_id: String,
320 pub owner: crate::LeaseOwnerIdentity,
321 pub lease_token: String,
322 pub fencing_token: u64,
323 pub session_lease_generation: u64,
327 pub mode: TurnInputClaimMode,
328 pub inputs: Vec<PendingTurnInput>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub applications: Vec<TurnInputApplication>,
331}
332
333impl TurnInputClaim {
334 pub fn completion(&self) -> TurnInputCompletion {
335 TurnInputCompletion {
336 session_id: self.session_id.clone(),
337 claim_id: self.claim_id.clone(),
338 lease_token: self.lease_token.clone(),
339 input_ids: self
340 .inputs
341 .iter()
342 .map(|input| input.input_id.clone())
343 .collect(),
344 applications: self.applications.clone(),
345 }
346 }
347
348 pub fn record_initial_turn_application(&mut self, turn_id: &str, committed_message_id: &str) {
349 self.applications = self
350 .inputs
351 .iter()
352 .filter(|input| {
353 input.input.items.iter().any(|item| match item {
354 crate::InputItem::Text { text } => !text.is_empty(),
355 crate::InputItem::Attachment { .. } => true,
356 })
357 })
358 .map(|input| TurnInputApplication {
359 input_id: input.input_id.clone(),
360 source_key: input.source_key.clone(),
361 turn_id: turn_id.to_string(),
362 committed_message_id: committed_message_id.to_string(),
363 checkpoint: None,
364 })
365 .collect();
366 }
367
368 pub fn record_checkpoint_applications(
369 &mut self,
370 turn_id: &str,
371 checkpoint: CheckpointKind,
372 committed_messages: &[crate::Message],
373 ) {
374 let committed_message_ids = committed_messages
375 .iter()
376 .map(|message| message.id.as_str())
377 .collect::<std::collections::HashSet<_>>();
378 self.applications = self
379 .inputs
380 .iter()
381 .filter_map(|input| {
382 let committed_message_id = ingress_message_id(&input.input_id);
383 committed_message_ids
384 .contains(committed_message_id.as_str())
385 .then(|| TurnInputApplication {
386 input_id: input.input_id.clone(),
387 source_key: input.source_key.clone(),
388 turn_id: turn_id.to_string(),
389 committed_message_id,
390 checkpoint: Some(checkpoint),
391 })
392 })
393 .collect();
394 }
395
396 pub fn accepted_turn_inputs(&self) -> Vec<crate::AcceptedInjectedTurnInput> {
397 self.inputs
398 .iter()
399 .filter_map(PendingTurnInput::accepted_input)
400 .collect()
401 }
402
403 pub async fn materialize_for_checkpoint(
404 &self,
405 attachment_store: &crate::SessionAttachmentStore,
406 attachment_source_policy: &dyn crate::AttachmentSourcePolicy,
407 ) -> Result<QueuedCheckpointTurnInput, String> {
408 let mut messages = Vec::new();
409 for input in &self.inputs {
410 if let Some(message) = committed_message_from_pending_input(
411 input,
412 attachment_store,
413 attachment_source_policy,
414 )
415 .await?
416 {
417 messages.push(message);
418 }
419 }
420 Ok(QueuedCheckpointTurnInput {
421 messages,
422 turn_causes: Vec::new(),
423 })
424 }
425
426 pub fn materialize_for_turn(&self) -> TurnInput {
427 let mut input_items = Vec::new();
428 let mut protocol_turn_options = None;
429 let mut trace_turn_id = None;
430 for pending in &self.inputs {
431 input_items.extend(pending.input.items.clone());
432 if protocol_turn_options.is_none() {
433 protocol_turn_options = pending.input.protocol_turn_options.clone();
434 }
435 if trace_turn_id.is_none() {
436 trace_turn_id = pending.input.trace_turn_id.clone();
437 }
438 }
439 TurnInput {
440 items: input_items,
441 protocol_turn_options,
442 trace_turn_id,
443 protocol_extension: None,
444 turn_context: crate::TurnContext::default(),
445 }
446 }
447}
448
449#[derive(Clone, Debug, Default)]
450pub struct QueuedCheckpointTurnInput {
451 pub messages: Vec<crate::Message>,
452 pub turn_causes: Vec<TurnCause>,
453}
454
455pub(crate) fn source_key_display_id(source: &str) -> String {
456 source
457 .strip_prefix("host:")
458 .or_else(|| source.strip_prefix("injection:"))
459 .unwrap_or(source)
460 .to_string()
461}
462
463pub(crate) fn plugin_message_from_turn_input(input: &TurnInput) -> Option<PluginMessage> {
464 let mut text = Vec::new();
465 let mut attachments = Vec::new();
466 for item in &input.items {
467 match item {
468 crate::InputItem::Text { text: item_text } if !item_text.is_empty() => {
469 text.push(item_text.clone());
470 }
471 crate::InputItem::Text { .. } => {}
472 crate::InputItem::Attachment { source } => attachments.push(source.clone()),
473 }
474 }
475 if text.is_empty() && attachments.is_empty() {
476 return None;
477 }
478 Some(PluginMessage {
479 id: None,
480 role: crate::MessageRole::User,
481 content: text.join("\n"),
482 origin: None,
483 parts: Vec::new(),
484 attachments,
485 })
486}
487
488async fn committed_message_from_pending_input(
489 pending: &PendingTurnInput,
490 attachment_store: &crate::SessionAttachmentStore,
491 attachment_source_policy: &dyn crate::AttachmentSourcePolicy,
492) -> Result<Option<crate::Message>, String> {
493 let normalized = super::io::normalize_input_items(
494 &pending.input.items,
495 attachment_store,
496 attachment_source_policy,
497 )
498 .await?;
499 let message_id = ingress_message_id(&pending.input_id);
500 let mut parts = Vec::new();
501 for item in normalized {
502 match item {
503 super::NormalizedItem::Text(text) if !text.is_empty() => {
504 let part_id = format!("{message_id}.p{}", parts.len());
505 parts.push(crate::Part {
506 id: part_id,
507 kind: crate::PartKind::Text,
508 content: text,
509 attachment: None,
510 tool_call_id: None,
511 tool_name: None,
512 tool_replay: None,
513 prune_state: crate::PruneState::Intact,
514 reasoning_meta: None,
515 response_meta: None,
516 });
517 }
518 super::NormalizedItem::Text(_) => {}
519 super::NormalizedItem::Attachment(source) => {
520 let part_id = format!("{message_id}.p{}", parts.len());
521 parts.push(crate::Part {
522 id: part_id,
523 kind: crate::PartKind::Attachment,
524 content: String::new(),
525 attachment: Some(crate::session_model::message::PartAttachment { source }),
526 tool_call_id: None,
527 tool_name: None,
528 tool_replay: None,
529 prune_state: crate::PruneState::Intact,
530 reasoning_meta: None,
531 response_meta: None,
532 });
533 }
534 }
535 }
536 if parts.is_empty() {
537 return Ok(None);
538 }
539 Ok(Some(crate::Message {
540 id: message_id,
541 role: crate::MessageRole::User,
542 origin: None,
543 parts: crate::shared_parts(parts),
544 }))
545}
546
547pub(crate) fn ingress_message_id(input_id: &str) -> String {
548 format!("m_ingress_{input_id}")
549}