1use crate::driver_registry::{LlmContentPart, LlmMessage, LlmMessageContent, LlmMessageRole};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize)]
8pub struct CompactRequest {
9 #[serde(skip)]
12 pub reasoning_state: Option<crate::reasoning_updates::ReasoningState>,
13 pub model: String,
15 #[serde(skip_serializing_if = "Vec::is_empty")]
17 pub input: Vec<CompactInputItem>,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub previous_response_id: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub instructions: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type")]
29pub enum CompactInputItem {
30 #[serde(rename = "configuration_update")]
31 ConfigurationUpdate { reasoning: ConfigurationReasoning },
32 #[serde(rename = "message")]
34 Message {
35 role: String,
37 content: CompactContent,
39 },
40 #[serde(rename = "function_call")]
42 FunctionCall {
43 call_id: String,
45 name: String,
47 arguments: String,
49 },
50 #[serde(rename = "function_call_output")]
52 FunctionCallOutput {
53 call_id: String,
55 output: String,
57 },
58 #[serde(rename = "compaction")]
60 Compaction {
61 encrypted_content: String,
63 },
64 #[serde(untagged)]
66 ProviderItem(serde_json::Value),
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ConfigurationReasoning {
71 pub effort: crate::model::ReasoningEffort,
72}
73
74impl From<&CompactOutputItem> for CompactInputItem {
75 fn from(item: &CompactOutputItem) -> Self {
76 match item {
77 CompactOutputItem::Message { role, content } => Self::Message {
78 role: role.clone(),
79 content: content.clone(),
80 },
81 CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
82 encrypted_content: encrypted_content.clone(),
83 },
84 CompactOutputItem::ProviderItem(item) => Self::ProviderItem(item.clone()),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(untagged)]
92pub enum CompactContent {
93 Text(String),
95 Parts(Vec<CompactContentPart>),
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "type")]
102pub enum CompactContentPart {
103 #[serde(rename = "input_text")]
105 InputText {
106 text: String,
108 },
109 #[serde(rename = "input_image")]
111 InputImage {
112 image_url: String,
114 },
115 #[serde(rename = "input_file")]
117 InputFile {
118 file_data: String,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 filename: Option<String>,
123 },
124}
125
126#[derive(Debug, Clone, Deserialize)]
128pub struct CompactResponse {
129 pub output: Vec<CompactOutputItem>,
131 pub usage: Option<CompactUsage>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137#[serde(tag = "type")]
138pub enum CompactOutputItem {
139 #[serde(rename = "message")]
141 Message {
142 role: String,
144 content: CompactContent,
146 },
147 #[serde(rename = "compaction")]
149 Compaction {
150 encrypted_content: String,
152 },
153 #[serde(untagged)]
156 ProviderItem(serde_json::Value),
157}
158
159impl<'de> Deserialize<'de> for CompactOutputItem {
160 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
161 use serde::de::Error;
162 let value = serde_json::Value::deserialize(deserializer)?;
163 let kind = value
164 .get("type")
165 .and_then(serde_json::Value::as_str)
166 .ok_or_else(|| D::Error::custom("compact output requires a string type"))?;
167 match kind {
168 "compaction" => {
169 let encrypted_content = value
170 .get("encrypted_content")
171 .and_then(serde_json::Value::as_str)
172 .ok_or_else(|| D::Error::custom("compaction requires encrypted_content"))?;
173 Ok(Self::Compaction {
174 encrypted_content: encrypted_content.to_owned(),
175 })
176 }
177 "message" => {
178 let role = value
179 .get("role")
180 .and_then(serde_json::Value::as_str)
181 .ok_or_else(|| D::Error::custom("compact message requires role"))?;
182 let content = value
183 .get("content")
184 .filter(|content| content.is_string() || content.is_array())
185 .ok_or_else(|| {
186 D::Error::custom("compact message requires text or multipart content")
187 })?;
188 if value.as_object().is_some_and(|object| object.len() == 3)
192 && let Ok(content) = serde_json::from_value::<CompactContent>(content.clone())
193 {
194 return Ok(Self::Message {
195 role: role.to_owned(),
196 content,
197 });
198 }
199 Ok(Self::ProviderItem(value))
200 }
201 _ => Ok(Self::ProviderItem(value)),
202 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize)]
208pub struct CompactUsage {
209 pub input_tokens: Option<u32>,
211 pub output_tokens: Option<u32>,
213 pub total_tokens: Option<u32>,
215 #[serde(default)]
217 pub cost: Option<f64>,
218}
219
220impl CompactInputItem {
221 pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
226 let mut items = Vec::new();
227 if let Some(effort) = msg.configuration_update {
228 items.push(Self::ConfigurationUpdate {
229 reasoning: ConfigurationReasoning { effort },
230 });
231 }
232 for part in &msg.reasoning {
233 if part.provider == "openai"
234 && let (Some(id), Some(encrypted)) = (&part.item_id, &part.encrypted)
235 {
236 let summary = match &part.text {
237 Some(crate::reasoning::ReasoningText::Summary { parts }) => parts
238 .iter()
239 .map(|text| serde_json::json!({"type": "summary_text", "text": text}))
240 .collect::<Vec<_>>(),
241 _ => Vec::new(),
242 };
243 items.push(Self::ProviderItem(serde_json::json!({
244 "type": "reasoning", "id": id, "encrypted_content": encrypted,
245 "summary": summary,
246 })));
247 }
248 }
249 let role = match msg.role {
250 LlmMessageRole::System => "developer",
251 LlmMessageRole::User => "user",
252 LlmMessageRole::Assistant => "assistant",
253 LlmMessageRole::Tool => "tool",
254 };
255
256 if msg.role == LlmMessageRole::Tool
257 && let Some(tool_call_id) = &msg.tool_call_id
258 {
259 let output = match &msg.content {
260 LlmMessageContent::Text(text) => text.clone(),
261 LlmMessageContent::Parts(parts) => parts
262 .iter()
263 .filter_map(|part| match part {
264 LlmContentPart::Text { text } => Some(text.clone()),
265 _ => None,
266 })
267 .collect::<Vec<_>>()
268 .join(""),
269 };
270 items.push(Self::FunctionCallOutput {
271 call_id: tool_call_id.clone(),
272 output,
273 });
274 return items;
275 }
276
277 let content = Self::content_from_llm_message(msg);
278 let has_content = match &content {
279 CompactContent::Text(text) => !text.is_empty(),
280 CompactContent::Parts(parts) => !parts.is_empty(),
281 };
282 if has_content || msg.tool_calls.is_none() {
283 let message = Self::Message {
284 role: role.to_string(),
285 content,
286 };
287 if msg.role == LlmMessageRole::Assistant
288 && let Some(phase) = msg.phase
289 {
290 let mut value = serde_json::json!(message);
291 value["phase"] = serde_json::json!(phase.as_provider_str());
292 items.push(Self::ProviderItem(value));
293 } else {
294 items.push(message);
295 }
296 }
297
298 if msg.role == LlmMessageRole::Assistant
299 && let Some(tool_calls) = &msg.tool_calls
300 {
301 items.extend(tool_calls.iter().map(|call| Self::FunctionCall {
302 call_id: call.id.clone(),
303 name: call.name.clone(),
304 arguments: call.arguments.to_string(),
305 }));
306 }
307 items
308 }
309
310 pub fn is_assistant_item(&self) -> bool {
311 match self {
312 Self::FunctionCall { .. } => true,
313 Self::Message { role, .. } => role == "assistant",
314 Self::ProviderItem(value) => {
315 value["role"] == "assistant"
316 || value["type"] == "reasoning"
317 || value["type"] == "function_call"
318 }
319 _ => false,
320 }
321 }
322
323 fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
324 match &msg.content {
325 LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
326 LlmMessageContent::Parts(parts) => {
327 let compact_parts = parts
328 .iter()
329 .filter_map(|part| match part {
330 LlmContentPart::Text { text } => {
331 Some(CompactContentPart::InputText { text: text.clone() })
332 }
333 LlmContentPart::Image { url } => Some(CompactContentPart::InputImage {
334 image_url: url.clone(),
335 }),
336 LlmContentPart::File { url, filename } => {
337 Some(CompactContentPart::InputFile {
338 file_data: url.clone(),
339 filename: filename.clone(),
340 })
341 }
342 LlmContentPart::Audio { .. } => None,
343 })
344 .collect::<Vec<_>>();
345 if compact_parts.len() == 1
346 && let CompactContentPart::InputText { text } = &compact_parts[0]
347 {
348 return CompactContent::Text(text.clone());
349 }
350 CompactContent::Parts(compact_parts)
351 }
352 }
353 }
354}
355
356pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
358 messages
359 .iter()
360 .flat_map(CompactInputItem::from_llm_message)
361 .collect()
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use serde_json::json;
368
369 #[test]
370 fn request_wire_covers_every_item_and_omits_absent_continuation_fields() {
371 let request = CompactRequest {
372 reasoning_state: Some(crate::reasoning_updates::ReasoningState {
373 epoch: "local-only-epoch".into(),
374 baseline: Some(crate::model::ReasoningEffort::Low),
375 effective: Some(crate::model::ReasoningEffort::High),
376 pending: Some(crate::model::ReasoningEffort::High),
377 }),
378 model: "model".into(),
379 input: vec![
380 CompactInputItem::Message {
381 role: "user".into(),
382 content: CompactContent::Text("hello".into()),
383 },
384 CompactInputItem::Message {
385 role: "assistant".into(),
386 content: CompactContent::Parts(vec![
387 CompactContentPart::InputText {
388 text: "image".into(),
389 },
390 CompactContentPart::InputImage {
391 image_url: "data:image/png;base64,abc".into(),
392 },
393 ]),
394 },
395 CompactInputItem::FunctionCall {
396 call_id: "call-1".into(),
397 name: "lookup".into(),
398 arguments: r#"{"city":"NYC"}"#.into(),
399 },
400 CompactInputItem::FunctionCallOutput {
401 call_id: "call-1".into(),
402 output: "result".into(),
403 },
404 CompactInputItem::Compaction {
405 encrypted_content: "opaque".into(),
406 },
407 CompactInputItem::ConfigurationUpdate {
408 reasoning: ConfigurationReasoning {
409 effort: crate::model::ReasoningEffort::High,
410 },
411 },
412 CompactInputItem::ProviderItem(
413 json!({"type":"reasoning","id":"rs-native","encrypted_content":"native-opaque"}),
414 ),
415 ],
416 previous_response_id: None,
417 instructions: Some("rules".into()),
418 };
419 assert_eq!(
420 serde_json::to_value(request).unwrap(),
421 json!({"model":"model","instructions":"rules","input":[
422 {"type":"message","role":"user","content":"hello"},
423 {"type":"message","role":"assistant","content":[{"type":"input_text","text":"image"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]},
424 {"type":"function_call","call_id":"call-1","name":"lookup","arguments":"{\"city\":\"NYC\"}"},
425 {"type":"function_call_output","call_id":"call-1","output":"result"},
426 {"type":"compaction","encrypted_content":"opaque"},
427 {"type":"configuration_update","reasoning":{"effort":"high"}},
428 {"type":"reasoning","id":"rs-native","encrypted_content":"native-opaque"}
429 ]})
430 );
431 assert_eq!(
432 serde_json::to_value(CompactRequest {
433 reasoning_state: None,
434 model: "model".into(),
435 input: vec![],
436 previous_response_id: Some("resp-previous".into()),
437 instructions: None
438 })
439 .unwrap(),
440 json!({"model":"model","previous_response_id":"resp-previous"})
441 );
442 }
443
444 #[test]
445 fn response_decoding_preserves_opaque_and_multipart_replay_with_optional_usage() {
446 let output = json!([
447 {"type":"message","role":"user","content":"hello"},
448 {"type":"message","role":"user","content":[{"type":"input_text","text":"see"},{"type":"input_image","image_url":"https://images.example/a.png"}]},
449 {"type":"compaction","encrypted_content":"opaque-secret"}
450 ]);
451 let response: CompactResponse = serde_json::from_value(json!({"output":output,"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150,"cost":0.04}})).unwrap();
452 assert_eq!(serde_json::to_value(&response.output).unwrap(), output);
453 let replay: Vec<_> = response.output.iter().map(CompactInputItem::from).collect();
454 assert_eq!(serde_json::to_value(replay).unwrap(), output);
455 let usage = response.usage.unwrap();
456 assert_eq!(
457 (
458 usage.input_tokens,
459 usage.output_tokens,
460 usage.total_tokens,
461 usage.cost
462 ),
463 (Some(100), Some(50), Some(150), Some(0.04))
464 );
465 for native in [
468 json!({"type":"message","role":"assistant","content":[],"phase":"final_answer","id":"msg-1"}),
469 json!({"type":"reasoning","encrypted_content":"opaque-reasoning","id":"rs-1"}),
470 json!({"type":"unknown","encrypted_content":"opaque-future"}),
471 ] {
472 let decoded: CompactOutputItem = serde_json::from_value(native.clone()).unwrap();
473 assert_eq!(
474 serde_json::to_value(CompactInputItem::from(&decoded)).unwrap(),
475 native
476 );
477 assert_eq!(serde_json::to_value(decoded).unwrap(), native);
478 }
479 let minimal: CompactResponse = serde_json::from_value(json!({"output":[]})).unwrap();
480 assert!(minimal.output.is_empty());
481 assert!(minimal.usage.is_none());
482 let sparse: CompactResponse =
483 serde_json::from_value(json!({"output":[],"usage":{"input_tokens":9}})).unwrap();
484 let usage = sparse.usage.unwrap();
485 assert_eq!(
486 (
487 usage.input_tokens,
488 usage.output_tokens,
489 usage.total_tokens,
490 usage.cost
491 ),
492 (Some(9), None, None, None)
493 );
494 for invalid in [
495 json!({"type":"compaction"}),
496 json!({"encrypted_content":"x"}),
497 json!({"type":"message","role":"user"}),
498 ] {
499 assert!(
500 serde_json::from_value::<CompactOutputItem>(invalid.clone()).is_err(),
501 "{invalid}"
502 );
503 }
504 }
505
506 #[test]
507 fn message_conversion_keeps_roles_call_order_and_supported_content() {
508 let mut assistant = LlmMessage::text(LlmMessageRole::Assistant, "checking");
509 assistant.configuration_update = Some(crate::model::ReasoningEffort::High);
510 assistant.phase = Some(crate::execution_phase::ExecutionPhase::Commentary);
511 assistant.reasoning = vec![
512 crate::reasoning::ReasoningContentPart::opaque("openai")
513 .with_item_id("rs-1")
514 .with_encrypted("private-replay")
515 .with_text(crate::reasoning::ReasoningText::Summary {
516 parts: vec!["first".into(), "second".into()],
517 }),
518 crate::reasoning::ReasoningContentPart::opaque("anthropic")
519 .with_item_id("foreign")
520 .with_encrypted("foreign-secret"),
521 crate::reasoning::ReasoningContentPart::opaque("openai")
522 .with_item_id("missing-encrypted"),
523 ];
524 assistant.tool_calls = Some(vec![crate::tool_types::ToolCall {
525 id: "call-1".into(),
526 name: "lookup".into(),
527 arguments: json!({"q":1}),
528 }]);
529 let mut result = LlmMessage::parts(
530 LlmMessageRole::Tool,
531 vec![
532 LlmContentPart::text("do"),
533 LlmContentPart::image("https://images.example/ignored.png"),
534 LlmContentPart::text("ne"),
535 ],
536 );
537 result.tool_call_id = Some("call-1".into());
538 let input = messages_to_compact_input(&[
539 LlmMessage::text(LlmMessageRole::System, "rules"),
540 LlmMessage::parts(
541 LlmMessageRole::User,
542 vec![
543 LlmContentPart::text("see"),
544 LlmContentPart::image("https://images.example/a.png"),
545 LlmContentPart::Audio {
546 url: "data:audio/wav;base64,aA==".into(),
547 },
548 ],
549 ),
550 assistant,
551 result,
552 LlmMessage::parts(
553 LlmMessageRole::User,
554 vec![LlmContentPart::text("only text")],
555 ),
556 ]);
557 assert_eq!(
558 serde_json::to_value(input).unwrap(),
559 json!([
560 {"type":"message","role":"developer","content":"rules"},
561 {"type":"message","role":"user","content":[{"type":"input_text","text":"see"},{"type":"input_image","image_url":"https://images.example/a.png"}]},
562 {"type":"configuration_update","reasoning":{"effort":"high"}},
563 {"type":"reasoning","id":"rs-1","encrypted_content":"private-replay","summary":[{"type":"summary_text","text":"first"},{"type":"summary_text","text":"second"}]},
564 {"type":"message","role":"assistant","content":"checking","phase":"commentary"},
565 {"type":"function_call","call_id":"call-1","name":"lookup","arguments":"{\"q\":1}"},
566 {"type":"function_call_output","call_id":"call-1","output":"done"},
567 {"type":"message","role":"user","content":"only text"}
568 ])
569 );
570 let mut calls_only = LlmMessage::text(LlmMessageRole::Assistant, "");
571 calls_only.tool_calls = Some(vec![crate::tool_types::ToolCall {
572 id: "call-2".into(),
573 name: "clock".into(),
574 arguments: json!({}),
575 }]);
576 assert_eq!(
577 serde_json::to_value(CompactInputItem::from_llm_message(&calls_only)).unwrap(),
578 json!([{"type":"function_call","call_id":"call-2","name":"clock","arguments":"{}"}])
579 );
580 }
581
582 #[test]
583 fn compact_file_part_preserves_data_url_and_filename() {
584 let part = CompactContentPart::InputFile {
585 file_data: "data:application/pdf;base64,JVBERi0=".to_string(),
586 filename: Some("report.pdf".to_string()),
587 };
588 let v = serde_json::to_value(&part).unwrap();
589 assert_eq!(v["type"], serde_json::json!("input_file"));
590 assert_eq!(
591 v["file_data"],
592 serde_json::json!("data:application/pdf;base64,JVBERi0=")
593 );
594 assert_eq!(v["filename"], serde_json::json!("report.pdf"));
595 }
596}