Skip to main content

vtcode_llm/provider/
responses_continuation.rs

1use super::Message;
2use std::borrow::Cow;
3use std::sync::Arc;
4
5#[derive(Debug, Clone, PartialEq)]
6pub struct ResponsesContinuationState<M = Message> {
7    pub response_id: String,
8    /// Full request history at the time the chain was recorded. Shared via
9    /// `Arc` so recording a chain after each turn is O(1), not O(history).
10    pub messages: Arc<Vec<M>>,
11}
12
13pub struct PreparedResponsesRequest<'a, M: Clone = Message> {
14    pub messages: Cow<'a, [M]>,
15    pub previous_response_id: Option<String>,
16    pub clear_stale_chain: bool,
17}
18
19pub fn responses_continuation_key(provider: &str, model: &str) -> Option<(String, String)> {
20    let provider = provider.trim().to_ascii_lowercase();
21    let model = model.trim();
22    if provider.is_empty() || model.is_empty() {
23        return None;
24    }
25
26    Some((provider, model.to_string()))
27}
28
29pub fn supports_responses_chaining(provider_name: &str, provider_supports_responses_compaction: bool) -> bool {
30    provider_supports_responses_compaction
31        || provider_name.eq_ignore_ascii_case("openai")
32        || provider_name.eq_ignore_ascii_case("openresponses")
33        || provider_name.eq_ignore_ascii_case("gemini")
34}
35
36pub fn records_responses_continuation_state(
37    provider_name: &str,
38    _provider_supports_responses_compaction: bool,
39) -> bool {
40    provider_name.eq_ignore_ascii_case("openresponses") || provider_name.eq_ignore_ascii_case("gemini")
41}
42
43/// Compatibility shim for older callers.
44///
45/// Responses continuation now preserves full request history. Use
46/// [`prepare_responses_continuation_request`] to derive request state.
47pub fn uses_incremental_responses_history(_provider_name: &str, _provider_supports_responses_compaction: bool) -> bool {
48    false
49}
50
51pub fn prepare_responses_continuation_request<'a, M>(
52    provider_name: &str,
53    provider_supports_responses_compaction: bool,
54    messages: &'a [M],
55    continuation: Option<&ResponsesContinuationState<M>>,
56) -> PreparedResponsesRequest<'a, M>
57where
58    M: Clone + PartialEq,
59{
60    if provider_name.eq_ignore_ascii_case("openai") {
61        return prepare_openai_responses_request(messages, continuation);
62    }
63
64    if !supports_responses_chaining(provider_name, provider_supports_responses_compaction) {
65        return PreparedResponsesRequest {
66            messages: Cow::Borrowed(messages),
67            previous_response_id: None,
68            clear_stale_chain: false,
69        };
70    }
71
72    if provider_name.eq_ignore_ascii_case("openresponses") || provider_name.eq_ignore_ascii_case("gemini") {
73        return PreparedResponsesRequest {
74            messages: Cow::Borrowed(messages),
75            previous_response_id: continuation.map(|chain| chain.response_id.clone()),
76            clear_stale_chain: false,
77        };
78    }
79
80    prepare_openai_responses_request(messages, continuation)
81}
82
83pub fn prepare_openai_responses_request<'a, M>(
84    messages: &'a [M],
85    _continuation: Option<&ResponsesContinuationState<M>>,
86) -> PreparedResponsesRequest<'a, M>
87where
88    M: Clone,
89{
90    PreparedResponsesRequest {
91        messages: Cow::Borrowed(messages),
92        previous_response_id: None,
93        clear_stale_chain: false,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::{
100        ResponsesContinuationState, prepare_openai_responses_request, prepare_responses_continuation_request,
101        records_responses_continuation_state, responses_continuation_key,
102    };
103    use crate::provider::Message;
104    use std::borrow::Cow;
105
106    #[test]
107    fn continuation_key_requires_non_empty_provider_and_model() {
108        assert_eq!(responses_continuation_key("", "gpt-5"), None);
109        assert_eq!(responses_continuation_key("openai", ""), None);
110        assert_eq!(responses_continuation_key("OpenAI", "gpt-5"), Some(("openai".to_string(), "gpt-5".to_string())));
111    }
112
113    #[test]
114    fn openai_does_not_record_normal_responses_continuation_state() {
115        assert!(!records_responses_continuation_state("openai", true));
116        assert!(!records_responses_continuation_state("OpenAI", false));
117    }
118
119    #[test]
120    fn provider_specific_responses_chaining_records_continuation_state() {
121        assert!(!records_responses_continuation_state("mycorp", true));
122        assert!(records_responses_continuation_state("gemini", false));
123        assert!(records_responses_continuation_state("openresponses", false));
124        assert!(!records_responses_continuation_state("anthropic", false));
125    }
126
127    #[test]
128    fn prepare_openai_request_keeps_full_history_without_previous_response_id() {
129        let messages = vec![
130            Message::user("hello".to_string()),
131            Message::user("continue".to_string()),
132        ];
133        let prepared = prepare_openai_responses_request(
134            &messages,
135            Some(&ResponsesContinuationState {
136                response_id: "resp_123".to_string(),
137                messages: vec![Message::user("hello".to_string())].into(),
138            }),
139        );
140
141        assert_eq!(prepared.previous_response_id, None);
142        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
143        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
144        assert!(!prepared.clear_stale_chain);
145    }
146
147    #[test]
148    fn prepare_openai_request_ignores_stale_chain_without_retry_recovery() {
149        let messages = vec![Message::user("continue".to_string())];
150        let prepared = prepare_openai_responses_request(
151            &messages,
152            Some(&ResponsesContinuationState {
153                response_id: "resp_123".to_string(),
154                messages: vec![Message::user("hello".to_string())].into(),
155            }),
156        );
157
158        assert_eq!(prepared.previous_response_id, None);
159        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
160        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
161        assert!(!prepared.clear_stale_chain);
162    }
163
164    #[test]
165    fn prepare_responses_continuation_request_keeps_openai_stateless() {
166        let messages = vec![
167            Message::user("hello".to_string()),
168            Message::user("continue".to_string()),
169        ];
170        let prepared = prepare_responses_continuation_request(
171            "openai",
172            false,
173            &messages,
174            Some(&ResponsesContinuationState {
175                response_id: "resp_123".to_string(),
176                messages: vec![Message::user("hello".to_string())].into(),
177            }),
178        );
179
180        assert_eq!(prepared.previous_response_id, None);
181        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
182        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
183        assert!(!prepared.clear_stale_chain);
184    }
185
186    #[test]
187    fn prepare_responses_continuation_request_keeps_compatible_provider_stateless() {
188        let messages = vec![
189            Message::user("hello".to_string()),
190            Message::user("continue".to_string()),
191        ];
192        let prepared = prepare_responses_continuation_request(
193            "mycorp",
194            true,
195            &messages,
196            Some(&ResponsesContinuationState {
197                response_id: "resp_123".to_string(),
198                messages: vec![Message::user("hello".to_string())].into(),
199            }),
200        );
201
202        assert_eq!(prepared.previous_response_id, None);
203        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
204        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
205        assert!(!prepared.clear_stale_chain);
206    }
207
208    #[test]
209    fn prepare_responses_continuation_request_keeps_full_history_for_gemini() {
210        let messages = vec![
211            Message::user("hello".to_string()),
212            Message::user("continue".to_string()),
213        ];
214        let prepared = prepare_responses_continuation_request(
215            "gemini",
216            false,
217            &messages,
218            Some(&ResponsesContinuationState {
219                response_id: "resp_123".to_string(),
220                messages: vec![Message::user("hello".to_string())].into(),
221            }),
222        );
223
224        assert_eq!(prepared.previous_response_id.as_deref(), Some("resp_123"));
225        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
226        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
227        assert!(!prepared.clear_stale_chain);
228    }
229
230    #[test]
231    fn prepare_responses_continuation_request_ignores_chain_for_unsupported_provider() {
232        let messages = vec![Message::user("hello".to_string())];
233        let prepared = prepare_responses_continuation_request(
234            "local",
235            false,
236            &messages,
237            Some(&ResponsesContinuationState {
238                response_id: "resp_123".to_string(),
239                messages: messages.clone().into(),
240            }),
241        );
242
243        assert_eq!(prepared.previous_response_id, None);
244        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
245        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
246        assert!(!prepared.clear_stale_chain);
247    }
248
249    #[test]
250    fn prepare_responses_continuation_request_ignores_openai_stale_chain_without_retry_recovery() {
251        let messages = vec![Message::user("continue".to_string())];
252        let prepared = prepare_responses_continuation_request(
253            "openai",
254            false,
255            &messages,
256            Some(&ResponsesContinuationState {
257                response_id: "resp_123".to_string(),
258                messages: vec![Message::user("hello".to_string())].into(),
259            }),
260        );
261
262        assert_eq!(prepared.previous_response_id, None);
263        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
264        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
265        assert!(!prepared.clear_stale_chain);
266    }
267}