1use serde::{Deserialize, Serialize};
7
8use super::identifiers::InputId;
9use crate::service::TurnToolOverlay;
10use crate::skills::SkillKey;
11use crate::types::{HandlingMode, RenderMetadata};
12
13#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RunApplyBoundary {
18 Immediate,
20 RunStart,
22 RunCheckpoint,
24}
25
26#[non_exhaustive]
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum CoreRenderable {
31 Text { text: String },
33 Blocks {
35 blocks: Vec<crate::types::ContentBlock>,
36 },
37 Json { value: serde_json::Value },
41 Reference { uri: String, label: Option<String> },
43}
44
45#[non_exhaustive]
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ConversationAppendRole {
50 User,
52 Assistant,
54 SystemNotice,
56 Tool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ConversationAppend {
63 pub role: ConversationAppendRole,
65 pub content: CoreRenderable,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ConversationContextAppend {
72 pub key: String,
74 pub content: CoreRenderable,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum RuntimeExecutionKind {
86 ContentTurn,
89 ResumePending,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
95pub struct RuntimeTurnMetadata {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub handling_mode: Option<HandlingMode>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub keep_alive: Option<bool>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub skill_references: Option<Vec<SkillKey>>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub flow_tool_overlay: Option<TurnToolOverlay>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub additional_instructions: Option<Vec<String>>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub model: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub provider: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub provider_params: Option<serde_json::Value>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub render_metadata: Option<RenderMetadata>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub execution_kind: Option<RuntimeExecutionKind>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct StagedRunInput {
133 pub boundary: RunApplyBoundary,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub appends: Vec<ConversationAppend>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub context_appends: Vec<ConversationContextAppend>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub contributing_input_ids: Vec<InputId>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub turn_metadata: Option<RuntimeTurnMetadata>,
147}
148
149#[non_exhaustive]
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "primitive_type", rename_all = "snake_case")]
157pub enum RunPrimitive {
158 StagedInput(StagedRunInput),
160 ImmediateAppend(ConversationAppend),
162 ImmediateContextAppend(ConversationContextAppend),
164}
165
166impl RunPrimitive {
167 pub fn contributing_input_ids(&self) -> &[InputId] {
169 match self {
170 RunPrimitive::StagedInput(staged) => &staged.contributing_input_ids,
171 RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => &[],
172 }
173 }
174
175 pub fn turn_metadata(&self) -> Option<&RuntimeTurnMetadata> {
176 match self {
177 RunPrimitive::StagedInput(staged) => staged.turn_metadata.as_ref(),
178 RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => None,
179 }
180 }
181
182 pub fn extract_content_input(&self) -> crate::types::ContentInput {
187 use crate::types::{ContentBlock, ContentInput};
188 match self {
189 RunPrimitive::StagedInput(staged) => {
190 let mut all_blocks = Vec::new();
191 for append in &staged.appends {
192 match &append.content {
193 CoreRenderable::Text { text } => {
194 all_blocks.push(ContentBlock::Text { text: text.clone() });
195 }
196 CoreRenderable::Blocks { blocks } => {
197 all_blocks.extend(blocks.iter().cloned());
198 }
199 _ => {}
200 }
201 }
202 if all_blocks.is_empty() {
203 ContentInput::Text(String::new())
204 } else if all_blocks.len() == 1 {
205 if let ContentBlock::Text { text } = &all_blocks[0] {
206 ContentInput::Text(text.clone())
207 } else {
208 ContentInput::Blocks(all_blocks)
209 }
210 } else {
211 ContentInput::Blocks(all_blocks)
212 }
213 }
214 RunPrimitive::ImmediateAppend(append) => match &append.content {
215 CoreRenderable::Text { text } => ContentInput::Text(text.clone()),
216 CoreRenderable::Blocks { blocks } => ContentInput::Blocks(blocks.clone()),
217 _ => ContentInput::Text(String::new()),
218 },
219 RunPrimitive::ImmediateContextAppend(ctx) => match &ctx.content {
220 CoreRenderable::Text { text } => ContentInput::Text(text.clone()),
221 CoreRenderable::Blocks { blocks } => ContentInput::Blocks(blocks.clone()),
222 _ => ContentInput::Text(String::new()),
223 },
224 }
225 }
226
227 pub fn apply_boundary(&self) -> RunApplyBoundary {
229 match self {
230 RunPrimitive::StagedInput(staged) => staged.boundary,
231 RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => {
232 RunApplyBoundary::Immediate
233 }
234 }
235 }
236
237 pub fn is_context_only_immediate(&self) -> bool {
240 matches!(
241 self,
242 RunPrimitive::StagedInput(staged)
243 if staged.appends.is_empty()
244 && !staged.context_appends.is_empty()
245 && staged.boundary == RunApplyBoundary::Immediate
246 )
247 }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn run_apply_boundary_serde_roundtrip() {
257 for boundary in [
258 RunApplyBoundary::Immediate,
259 RunApplyBoundary::RunStart,
260 RunApplyBoundary::RunCheckpoint,
261 ] {
262 let json = serde_json::to_value(boundary).unwrap();
263 let parsed: RunApplyBoundary = serde_json::from_value(json).unwrap();
264 assert_eq!(boundary, parsed);
265 }
266 }
267
268 #[test]
269 fn core_renderable_text_serde() {
270 let r = CoreRenderable::Text {
271 text: "hello".into(),
272 };
273 let json = serde_json::to_value(&r).unwrap();
274 assert_eq!(json["type"], "text");
275 assert_eq!(json["text"], "hello");
276 let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
277 assert_eq!(r, parsed);
278 }
279
280 #[test]
281 fn core_renderable_json_serde() {
282 let r = CoreRenderable::Json {
283 value: serde_json::json!({"key": "val"}),
284 };
285 let json = serde_json::to_value(&r).unwrap();
286 assert_eq!(json["type"], "json");
287 let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
288 assert_eq!(r, parsed);
289 }
290
291 fn make_staged(appends: Vec<ConversationAppend>) -> RunPrimitive {
294 RunPrimitive::StagedInput(StagedRunInput {
295 boundary: RunApplyBoundary::RunStart,
296 appends,
297 context_appends: vec![],
298 contributing_input_ids: vec![],
299 turn_metadata: None,
300 })
301 }
302
303 #[test]
304 fn extract_content_from_staged_text() {
305 let p = make_staged(vec![ConversationAppend {
306 role: ConversationAppendRole::User,
307 content: CoreRenderable::Text {
308 text: "hello".into(),
309 },
310 }]);
311 assert_eq!(
312 p.extract_content_input(),
313 crate::types::ContentInput::Text("hello".into())
314 );
315 }
316
317 #[test]
318 fn extract_content_from_staged_blocks() {
319 let p = make_staged(vec![ConversationAppend {
320 role: ConversationAppendRole::User,
321 content: CoreRenderable::Blocks {
322 blocks: vec![
323 crate::types::ContentBlock::Text { text: "a".into() },
324 crate::types::ContentBlock::Text { text: "b".into() },
325 ],
326 },
327 }]);
328 let result = p.extract_content_input();
329 assert!(
330 matches!(&result, crate::types::ContentInput::Blocks(blocks) if blocks.len() == 2),
331 "expected Blocks with 2 elements, got {result:?}"
332 );
333 }
334
335 #[test]
336 fn extract_content_from_staged_empty() {
337 let p = make_staged(vec![]);
338 assert_eq!(
339 p.extract_content_input(),
340 crate::types::ContentInput::Text(String::new())
341 );
342 }
343
344 #[test]
345 fn extract_content_single_text_block_collapses() {
346 let p = make_staged(vec![ConversationAppend {
347 role: ConversationAppendRole::User,
348 content: CoreRenderable::Blocks {
349 blocks: vec![crate::types::ContentBlock::Text {
350 text: "single".into(),
351 }],
352 },
353 }]);
354 assert_eq!(
355 p.extract_content_input(),
356 crate::types::ContentInput::Text("single".into())
357 );
358 }
359
360 #[test]
363 fn context_only_immediate_true() {
364 let p = RunPrimitive::StagedInput(StagedRunInput {
365 boundary: RunApplyBoundary::Immediate,
366 appends: vec![],
367 context_appends: vec![ConversationContextAppend {
368 key: "k".into(),
369 content: CoreRenderable::Text { text: "ctx".into() },
370 }],
371 contributing_input_ids: vec![],
372 turn_metadata: None,
373 });
374 assert!(p.is_context_only_immediate());
375 }
376
377 #[test]
378 fn context_only_immediate_false_with_appends() {
379 let p = RunPrimitive::StagedInput(StagedRunInput {
380 boundary: RunApplyBoundary::Immediate,
381 appends: vec![ConversationAppend {
382 role: ConversationAppendRole::User,
383 content: CoreRenderable::Text { text: "hi".into() },
384 }],
385 context_appends: vec![ConversationContextAppend {
386 key: "k".into(),
387 content: CoreRenderable::Text { text: "ctx".into() },
388 }],
389 contributing_input_ids: vec![],
390 turn_metadata: None,
391 });
392 assert!(!p.is_context_only_immediate());
393 }
394
395 #[test]
396 fn context_only_immediate_false_wrong_boundary() {
397 let p = RunPrimitive::StagedInput(StagedRunInput {
398 boundary: RunApplyBoundary::RunCheckpoint,
399 appends: vec![],
400 context_appends: vec![ConversationContextAppend {
401 key: "k".into(),
402 content: CoreRenderable::Text { text: "ctx".into() },
403 }],
404 contributing_input_ids: vec![],
405 turn_metadata: None,
406 });
407 assert!(!p.is_context_only_immediate());
408 }
409
410 #[test]
411 fn non_staged_is_not_context_only() {
412 let p = RunPrimitive::ImmediateAppend(ConversationAppend {
413 role: ConversationAppendRole::User,
414 content: CoreRenderable::Text { text: "hi".into() },
415 });
416 assert!(!p.is_context_only_immediate());
417 }
418
419 #[test]
420 fn core_renderable_reference_serde() {
421 let r = CoreRenderable::Reference {
422 uri: "file:///tmp/a.txt".into(),
423 label: Some("a file".into()),
424 };
425 let json = serde_json::to_value(&r).unwrap();
426 assert_eq!(json["type"], "reference");
427 let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
428 assert_eq!(r, parsed);
429 }
430
431 #[test]
432 fn execution_kind_serde_round_trip() {
433 for kind in [
434 RuntimeExecutionKind::ContentTurn,
435 RuntimeExecutionKind::ResumePending,
436 ] {
437 let json = serde_json::to_value(kind).unwrap();
438 let parsed: RuntimeExecutionKind = serde_json::from_value(json.clone()).unwrap();
439 assert_eq!(kind, parsed);
440 }
441 assert_eq!(
443 serde_json::to_value(RuntimeExecutionKind::ContentTurn).unwrap(),
444 serde_json::Value::String("content_turn".into())
445 );
446 assert_eq!(
447 serde_json::to_value(RuntimeExecutionKind::ResumePending).unwrap(),
448 serde_json::Value::String("resume_pending".into())
449 );
450 }
451
452 #[test]
453 fn turn_metadata_execution_kind_defaults_to_none() {
454 let meta = RuntimeTurnMetadata::default();
455 assert_eq!(meta.execution_kind, None);
456 }
457
458 #[test]
459 fn turn_metadata_execution_kind_round_trips() {
460 let meta = RuntimeTurnMetadata {
461 execution_kind: Some(RuntimeExecutionKind::ContentTurn),
462 ..Default::default()
463 };
464 let json = serde_json::to_value(&meta).unwrap();
465 assert_eq!(json["execution_kind"], "content_turn");
466 let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
467 assert_eq!(
468 parsed.execution_kind,
469 Some(RuntimeExecutionKind::ContentTurn)
470 );
471 }
472
473 #[test]
474 fn turn_metadata_without_execution_kind_deserializes() {
475 let json = serde_json::json!({});
477 let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
478 assert_eq!(parsed.execution_kind, None);
479 }
480
481 #[test]
482 fn conversation_append_role_serde() {
483 for role in [
484 ConversationAppendRole::User,
485 ConversationAppendRole::Assistant,
486 ConversationAppendRole::SystemNotice,
487 ConversationAppendRole::Tool,
488 ] {
489 let json = serde_json::to_value(role).unwrap();
490 let parsed: ConversationAppendRole = serde_json::from_value(json).unwrap();
491 assert_eq!(role, parsed);
492 }
493 }
494
495 #[test]
496 fn conversation_append_serde() {
497 let append = ConversationAppend {
498 role: ConversationAppendRole::User,
499 content: CoreRenderable::Text {
500 text: "hello".into(),
501 },
502 };
503 let json = serde_json::to_value(&append).unwrap();
504 let parsed: ConversationAppend = serde_json::from_value(json).unwrap();
505 assert_eq!(append, parsed);
506 }
507
508 #[test]
509 fn staged_run_input_serde() {
510 let staged = StagedRunInput {
511 boundary: RunApplyBoundary::RunStart,
512 appends: vec![ConversationAppend {
513 role: ConversationAppendRole::User,
514 content: CoreRenderable::Text {
515 text: "prompt".into(),
516 },
517 }],
518 context_appends: vec![],
519 contributing_input_ids: vec![InputId::new()],
520 turn_metadata: Some(RuntimeTurnMetadata {
521 keep_alive: Some(true),
522 ..Default::default()
523 }),
524 };
525 let json = serde_json::to_value(&staged).unwrap();
526 let parsed: StagedRunInput = serde_json::from_value(json).unwrap();
527 assert_eq!(staged, parsed);
528 }
529
530 #[test]
531 fn run_primitive_staged_input_serde() {
532 let primitive = RunPrimitive::StagedInput(StagedRunInput {
533 boundary: RunApplyBoundary::RunStart,
534 appends: vec![],
535 context_appends: vec![],
536 contributing_input_ids: vec![InputId::new(), InputId::new()],
537 turn_metadata: None,
538 });
539 let json = serde_json::to_value(&primitive).unwrap();
540 assert_eq!(json["primitive_type"], "staged_input");
541 let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
542 assert_eq!(primitive, parsed);
543 }
544
545 #[test]
546 fn run_primitive_immediate_append_serde() {
547 let primitive = RunPrimitive::ImmediateAppend(ConversationAppend {
548 role: ConversationAppendRole::SystemNotice,
549 content: CoreRenderable::Text {
550 text: "notice".into(),
551 },
552 });
553 let json = serde_json::to_value(&primitive).unwrap();
554 assert_eq!(json["primitive_type"], "immediate_append");
555 let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
556 assert_eq!(primitive, parsed);
557 }
558
559 #[test]
560 fn run_primitive_contributing_input_ids() {
561 let ids = vec![InputId::new(), InputId::new()];
562 let primitive = RunPrimitive::StagedInput(StagedRunInput {
563 boundary: RunApplyBoundary::RunStart,
564 appends: vec![],
565 context_appends: vec![],
566 contributing_input_ids: ids.clone(),
567 turn_metadata: None,
568 });
569 assert_eq!(primitive.contributing_input_ids(), &ids);
570
571 let immediate = RunPrimitive::ImmediateAppend(ConversationAppend {
572 role: ConversationAppendRole::User,
573 content: CoreRenderable::Text { text: "hi".into() },
574 });
575 assert!(immediate.contributing_input_ids().is_empty());
576 }
577
578 #[test]
579 fn conversation_context_append_serde() {
580 let ctx = ConversationContextAppend {
581 key: "peers".into(),
582 content: CoreRenderable::Json {
583 value: serde_json::json!(["peer1", "peer2"]),
584 },
585 };
586 let json = serde_json::to_value(&ctx).unwrap();
587 let parsed: ConversationContextAppend = serde_json::from_value(json).unwrap();
588 assert_eq!(ctx, parsed);
589 }
590}