diff --git a/crates/arc-agent/src/history.rs b/crates/arc-agent/src/history.rs index b80074050..b0cb660b3 100644 --- a/crates/arc-agent/src/history.rs +++ b/crates/arc-agent/src/history.rs @@ -39,7 +39,7 @@ impl History { for turn in &mut self.turns { if let Turn::Assistant { provider_parts, .. } = turn { provider_parts.retain(|p| { - !matches!(p, ContentPart::Other { kind, .. } if kind == "openai_reasoning" || kind == "openai_message") + !matches!(p, ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING || kind == ContentPart::OPENAI_MESSAGE) }); } } @@ -285,7 +285,7 @@ mod tests { fn assistant_turn_preserves_provider_parts() { let mut history = History::default(); let reasoning_item = ContentPart::Other { - kind: "openai_reasoning".to_string(), + kind: ContentPart::OPENAI_REASONING.to_string(), data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); @@ -301,7 +301,7 @@ mod tests { assert_eq!(messages.len(), 1); // Provider parts come first, then tool calls assert!( - matches!(&messages[0].content[0], ContentPart::Other { kind, .. } if kind == "openai_reasoning") + matches!(&messages[0].content[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING) ); assert!(matches!(&messages[0].content[1], ContentPart::ToolCall(_))); } @@ -421,7 +421,7 @@ mod tests { timestamp: SystemTime::now(), }); let reasoning = ContentPart::Other { - kind: "openai_reasoning".into(), + kind: ContentPart::OPENAI_REASONING.into(), data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); @@ -501,7 +501,7 @@ mod tests { content: format!("response {i}"), tool_calls: vec![], provider_parts: vec![ContentPart::Other { - kind: "openai_reasoning".into(), + kind: ContentPart::OPENAI_REASONING.into(), data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), }], usage: Usage::default(), diff --git a/crates/arc-llm/src/providers/openai.rs b/crates/arc-llm/src/providers/openai.rs index e0d87a5cc..4df689e80 100644 --- a/crates/arc-llm/src/providers/openai.rs +++ b/crates/arc-llm/src/providers/openai.rs @@ -224,7 +224,7 @@ fn translate_input(messages: &[Message]) -> (Option, Vec (Option, Vec + if kind == ContentPart::OPENAI_REASONING || kind == ContentPart::OPENAI_MESSAGE => { input.push(data.clone()); } @@ -414,7 +414,7 @@ fn parse_output(output: &[serde_json::Value]) -> (Vec, bool) { // The item's `id` and `status` fields are required so that reasoning // items preceding it can find their "required following item." parts.push(ContentPart::Other { - kind: "openai_message".to_string(), + kind: ContentPart::OPENAI_MESSAGE.to_string(), data: item.clone(), }); if let Some(content) = item.get("content").and_then(|c| c.as_array()) { @@ -433,7 +433,7 @@ fn parse_output(output: &[serde_json::Value]) -> (Vec, bool) { } Some("reasoning") => { parts.push(ContentPart::Other { - kind: "openai_reasoning".to_string(), + kind: ContentPart::OPENAI_REASONING.to_string(), data: item.clone(), }); } @@ -827,14 +827,14 @@ fn handle_response_completed( // Reasoning items must precede function calls for Responses API round-trip for item in &state.reasoning_items { content_parts.push(ContentPart::Other { - kind: "openai_reasoning".to_string(), + kind: ContentPart::OPENAI_REASONING.to_string(), data: item.clone(), }); } // Preserve full message output items for Responses API round-tripping for item in &state.message_items { content_parts.push(ContentPart::Other { - kind: "openai_message".to_string(), + kind: ContentPart::OPENAI_MESSAGE.to_string(), data: item.clone(), }); } @@ -1330,7 +1330,7 @@ mod tests { // First part is the reasoning item match &parts[0] { ContentPart::Other { kind, data } => { - assert_eq!(kind, "openai_reasoning"); + assert_eq!(kind, ContentPart::OPENAI_REASONING); assert_eq!(data["type"], "reasoning"); assert_eq!(data["id"], "rs_abc123"); } @@ -1367,8 +1367,8 @@ mod tests { assert!(has_tool_calls); // reasoning + openai_message + text + function_call assert_eq!(parts.len(), 4); - assert!(matches!(&parts[0], ContentPart::Other { kind, .. } if kind == "openai_reasoning")); - assert!(matches!(&parts[1], ContentPart::Other { kind, data } if kind == "openai_message" && data["id"] == "msg_xyz")); + assert!(matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING)); + assert!(matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz")); assert!(matches!(&parts[2], ContentPart::Text(t) if t == "Hello")); assert!(matches!(&parts[3], ContentPart::ToolCall(_))); } @@ -1387,7 +1387,7 @@ mod tests { role: Role::Assistant, content: vec![ ContentPart::Other { - kind: "openai_reasoning".to_string(), + kind: ContentPart::OPENAI_REASONING.to_string(), data: reasoning, }, ContentPart::ToolCall(tc), @@ -1431,11 +1431,11 @@ mod tests { role: Role::Assistant, content: vec![ ContentPart::Other { - kind: "openai_reasoning".to_string(), + kind: ContentPart::OPENAI_REASONING.to_string(), data: reasoning, }, ContentPart::Other { - kind: "openai_message".to_string(), + kind: ContentPart::OPENAI_MESSAGE.to_string(), data: opaque_message, }, ContentPart::text("Checking now."), diff --git a/crates/arc-llm/src/types.rs b/crates/arc-llm/src/types.rs index ce96f8e5d..cf9a3ef51 100644 --- a/crates/arc-llm/src/types.rs +++ b/crates/arc-llm/src/types.rs @@ -223,6 +223,11 @@ impl<'de> Deserialize<'de> for ContentPart { } impl ContentPart { + /// Kind string for opaque OpenAI reasoning output items. + pub const OPENAI_REASONING: &str = "openai_reasoning"; + /// Kind string for opaque OpenAI message output items. + pub const OPENAI_MESSAGE: &str = "openai_message"; + pub fn text(text: impl Into) -> Self { Self::Text(text.into()) }