Skip to main content

vtcode_llm/provider/
responses_continuation.rs

1use super::Message;
2use std::borrow::Cow;
3use std::collections::HashMap;
4use std::sync::Arc;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct ResponsesContinuationState<M = Message> {
8    pub response_id: String,
9    /// Full request history at the time the chain was recorded. Shared via
10    /// `Arc` so recording a chain after each turn is O(1), not O(history).
11    pub messages: Arc<Vec<M>>,
12}
13
14/// Session-scoped store of provider continuation chains.
15///
16/// One owner for the chain key, response-id, and history semantics so the
17/// interactive runloop and the headless runner cannot drift apart.
18#[derive(Debug, Default, Clone)]
19pub struct ResponsesContinuationStore {
20    chains: HashMap<(String, String), ResponsesContinuationState>,
21}
22
23impl ResponsesContinuationStore {
24    /// The recorded chain for a provider and model, if any.
25    pub fn get(&self, provider: &str, model: &str) -> Option<&ResponsesContinuationState> {
26        responses_continuation_key(provider, model).and_then(|key| self.chains.get(&key))
27    }
28
29    /// Record a chain, or drop the existing one when the response id is empty.
30    pub fn set(&mut self, provider: &str, model: &str, response_id: Option<&str>, messages: Arc<Vec<Message>>) {
31        let Some(key) = responses_continuation_key(provider, model) else {
32            return;
33        };
34        let Some(response_id) = response_id.map(str::trim).filter(|value| !value.is_empty()) else {
35            self.chains.remove(&key);
36            return;
37        };
38
39        self.chains
40            .insert(key, ResponsesContinuationState { response_id: response_id.to_string(), messages });
41    }
42
43    /// Drop the chain recorded for one provider and model.
44    pub fn clear_for(&mut self, provider: &str, model: &str) {
45        if let Some(key) = responses_continuation_key(provider, model) {
46            self.chains.remove(&key);
47        }
48    }
49
50    /// Drop every recorded chain.
51    pub fn clear(&mut self) {
52        self.chains.clear();
53    }
54
55    /// Whether no chain is recorded.
56    pub fn is_empty(&self) -> bool {
57        self.chains.is_empty()
58    }
59
60    /// Prepare request messages for a continuation-aware provider.
61    ///
62    /// A stale chain that the provider rejects is dropped here so the next
63    /// request starts from the full history.
64    pub fn prepare_request_messages<'a>(
65        &mut self,
66        provider_name: &str,
67        provider_supports_responses_compaction: bool,
68        model: &str,
69        messages: &'a [Message],
70    ) -> (Cow<'a, [Message]>, Option<String>) {
71        let key = responses_continuation_key(provider_name, model);
72        let continuation = key.as_ref().and_then(|key| self.chains.get(key));
73        let prepared = prepare_responses_continuation_request(
74            provider_name,
75            provider_supports_responses_compaction,
76            messages,
77            continuation,
78        );
79        if prepared.clear_stale_chain
80            && let Some(key) = key
81        {
82            self.chains.remove(&key);
83        }
84
85        (prepared.messages, prepared.previous_response_id)
86    }
87}
88
89pub struct PreparedResponsesRequest<'a, M: Clone = Message> {
90    pub messages: Cow<'a, [M]>,
91    pub previous_response_id: Option<String>,
92    pub clear_stale_chain: bool,
93}
94
95pub fn responses_continuation_key(provider: &str, model: &str) -> Option<(String, String)> {
96    let provider = provider.trim().to_ascii_lowercase();
97    let model = model.trim();
98    if provider.is_empty() || model.is_empty() {
99        return None;
100    }
101
102    Some((provider, model.to_string()))
103}
104
105pub fn supports_responses_chaining(provider_name: &str, provider_supports_responses_compaction: bool) -> bool {
106    provider_supports_responses_compaction
107        || provider_name.eq_ignore_ascii_case("openai")
108        || provider_name.eq_ignore_ascii_case("openresponses")
109        || provider_name.eq_ignore_ascii_case("gemini")
110}
111
112pub fn records_responses_continuation_state(
113    provider_name: &str,
114    _provider_supports_responses_compaction: bool,
115) -> bool {
116    provider_name.eq_ignore_ascii_case("openresponses") || provider_name.eq_ignore_ascii_case("gemini")
117}
118
119/// Compatibility shim for older callers.
120///
121/// Responses continuation now preserves full request history. Use
122/// [`prepare_responses_continuation_request`] to derive request state.
123pub fn uses_incremental_responses_history(_provider_name: &str, _provider_supports_responses_compaction: bool) -> bool {
124    false
125}
126
127pub fn prepare_responses_continuation_request<'a, M>(
128    provider_name: &str,
129    provider_supports_responses_compaction: bool,
130    messages: &'a [M],
131    continuation: Option<&ResponsesContinuationState<M>>,
132) -> PreparedResponsesRequest<'a, M>
133where
134    M: Clone + PartialEq,
135{
136    if provider_name.eq_ignore_ascii_case("openai") {
137        return prepare_openai_responses_request(messages, continuation);
138    }
139
140    if !supports_responses_chaining(provider_name, provider_supports_responses_compaction) {
141        return PreparedResponsesRequest {
142            messages: Cow::Borrowed(messages),
143            previous_response_id: None,
144            clear_stale_chain: false,
145        };
146    }
147
148    if provider_name.eq_ignore_ascii_case("openresponses") || provider_name.eq_ignore_ascii_case("gemini") {
149        return PreparedResponsesRequest {
150            messages: Cow::Borrowed(messages),
151            previous_response_id: continuation.map(|chain| chain.response_id.clone()),
152            clear_stale_chain: false,
153        };
154    }
155
156    prepare_openai_responses_request(messages, continuation)
157}
158
159pub fn prepare_openai_responses_request<'a, M>(
160    messages: &'a [M],
161    _continuation: Option<&ResponsesContinuationState<M>>,
162) -> PreparedResponsesRequest<'a, M>
163where
164    M: Clone,
165{
166    PreparedResponsesRequest {
167        messages: Cow::Borrowed(messages),
168        previous_response_id: None,
169        clear_stale_chain: false,
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{
176        ResponsesContinuationState, ResponsesContinuationStore, prepare_openai_responses_request,
177        prepare_responses_continuation_request, records_responses_continuation_state, responses_continuation_key,
178    };
179    use crate::provider::Message;
180    use std::borrow::Cow;
181    use std::sync::Arc;
182
183    #[test]
184    fn continuation_store_records_reads_and_clears_one_chain_at_a_time() {
185        let mut store = ResponsesContinuationStore::default();
186        let openai_messages = Arc::new(vec![Message::user("openai hello".to_string())]);
187        let gemini_messages = Arc::new(vec![Message::user("gemini hello".to_string())]);
188
189        store.set("openai", "gpt-5.6-sol", Some("resp_openai"), Arc::clone(&openai_messages));
190        store.set("gemini", "gemini-2.5-pro", Some("resp_gemini"), Arc::clone(&gemini_messages));
191
192        assert_eq!(store.get("openai", "gpt-5.6-sol").map(|chain| chain.response_id.as_str()), Some("resp_openai"));
193        assert_eq!(store.get("gemini", "gemini-2.5-pro").map(|chain| chain.response_id.as_str()), Some("resp_gemini"));
194        assert!(store.get("openai", "gpt-5.6-terra").is_none());
195        assert!(!store.is_empty());
196
197        store.clear_for("openai", "gpt-5.6-sol");
198        assert!(store.get("openai", "gpt-5.6-sol").is_none());
199        assert_eq!(store.get("gemini", "gemini-2.5-pro").map(|chain| chain.response_id.as_str()), Some("resp_gemini"));
200
201        store.clear();
202        assert!(store.is_empty());
203    }
204
205    #[test]
206    fn continuation_store_normalizes_the_provider_and_drops_an_empty_response_id() {
207        let mut store = ResponsesContinuationStore::default();
208        store.set("OpenAI", "gpt-5.6-sol", Some("resp_1"), Arc::new(vec![Message::user("hi".to_string())]));
209
210        // The provider key is normalized, so a differently-cased lookup finds it.
211        assert!(store.get("openai", "gpt-5.6-sol").is_some());
212        assert!(store.get("OPENAI", "gpt-5.6-sol").is_some());
213
214        // An empty response id drops the recorded chain instead of storing it.
215        store.set("openai", "gpt-5.6-sol", Some("   "), Arc::new(vec![]));
216        assert!(store.get("openai", "gpt-5.6-sol").is_none());
217        assert!(store.is_empty());
218    }
219
220    #[test]
221    fn continuation_store_prepares_provider_specific_requests() {
222        let mut store = ResponsesContinuationStore::default();
223        let prior = vec![Message::user("hello".to_string())];
224        let current = vec![
225            Message::user("hello".to_string()),
226            Message::user("continue".to_string()),
227        ];
228
229        store.set("gemini", "gemini-2.5-pro", Some("resp_gemini"), Arc::new(prior.clone()));
230        let (messages, previous_response_id) =
231            store.prepare_request_messages("gemini", false, "gemini-2.5-pro", &current);
232
233        assert_eq!(previous_response_id.as_deref(), Some("resp_gemini"));
234        assert!(matches!(messages, Cow::Borrowed(_)));
235        assert_eq!(messages.as_ref(), current.as_slice());
236
237        // OpenAI stays stateless: the recorded chain never becomes a previous id.
238        store.set("openai", "gpt-5.6-sol", Some("resp_openai"), Arc::new(prior.clone()));
239        let (messages, previous_response_id) = store.prepare_request_messages("openai", false, "gpt-5.6-sol", &current);
240        assert_eq!(previous_response_id, None);
241        assert_eq!(messages.as_ref(), current.as_slice());
242    }
243
244    #[test]
245    fn continuation_key_requires_non_empty_provider_and_model() {
246        assert_eq!(responses_continuation_key("", "gpt-5"), None);
247        assert_eq!(responses_continuation_key("openai", ""), None);
248        assert_eq!(responses_continuation_key("OpenAI", "gpt-5"), Some(("openai".to_string(), "gpt-5".to_string())));
249    }
250
251    #[test]
252    fn openai_does_not_record_normal_responses_continuation_state() {
253        assert!(!records_responses_continuation_state("openai", true));
254        assert!(!records_responses_continuation_state("OpenAI", false));
255    }
256
257    #[test]
258    fn provider_specific_responses_chaining_records_continuation_state() {
259        assert!(!records_responses_continuation_state("mycorp", true));
260        assert!(records_responses_continuation_state("gemini", false));
261        assert!(records_responses_continuation_state("openresponses", false));
262        assert!(!records_responses_continuation_state("anthropic", false));
263    }
264
265    #[test]
266    fn prepare_openai_request_keeps_full_history_without_previous_response_id() {
267        let messages = vec![
268            Message::user("hello".to_string()),
269            Message::user("continue".to_string()),
270        ];
271        let prepared = prepare_openai_responses_request(
272            &messages,
273            Some(&ResponsesContinuationState {
274                response_id: "resp_123".to_string(),
275                messages: vec![Message::user("hello".to_string())].into(),
276            }),
277        );
278
279        assert_eq!(prepared.previous_response_id, None);
280        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
281        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
282        assert!(!prepared.clear_stale_chain);
283    }
284
285    #[test]
286    fn prepare_openai_request_ignores_stale_chain_without_retry_recovery() {
287        let messages = vec![Message::user("continue".to_string())];
288        let prepared = prepare_openai_responses_request(
289            &messages,
290            Some(&ResponsesContinuationState {
291                response_id: "resp_123".to_string(),
292                messages: vec![Message::user("hello".to_string())].into(),
293            }),
294        );
295
296        assert_eq!(prepared.previous_response_id, None);
297        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
298        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
299        assert!(!prepared.clear_stale_chain);
300    }
301
302    #[test]
303    fn prepare_responses_continuation_request_keeps_openai_stateless() {
304        let messages = vec![
305            Message::user("hello".to_string()),
306            Message::user("continue".to_string()),
307        ];
308        let prepared = prepare_responses_continuation_request(
309            "openai",
310            false,
311            &messages,
312            Some(&ResponsesContinuationState {
313                response_id: "resp_123".to_string(),
314                messages: vec![Message::user("hello".to_string())].into(),
315            }),
316        );
317
318        assert_eq!(prepared.previous_response_id, None);
319        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
320        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
321        assert!(!prepared.clear_stale_chain);
322    }
323
324    #[test]
325    fn prepare_responses_continuation_request_keeps_compatible_provider_stateless() {
326        let messages = vec![
327            Message::user("hello".to_string()),
328            Message::user("continue".to_string()),
329        ];
330        let prepared = prepare_responses_continuation_request(
331            "mycorp",
332            true,
333            &messages,
334            Some(&ResponsesContinuationState {
335                response_id: "resp_123".to_string(),
336                messages: vec![Message::user("hello".to_string())].into(),
337            }),
338        );
339
340        assert_eq!(prepared.previous_response_id, None);
341        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
342        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
343        assert!(!prepared.clear_stale_chain);
344    }
345
346    #[test]
347    fn prepare_responses_continuation_request_keeps_full_history_for_gemini() {
348        let messages = vec![
349            Message::user("hello".to_string()),
350            Message::user("continue".to_string()),
351        ];
352        let prepared = prepare_responses_continuation_request(
353            "gemini",
354            false,
355            &messages,
356            Some(&ResponsesContinuationState {
357                response_id: "resp_123".to_string(),
358                messages: vec![Message::user("hello".to_string())].into(),
359            }),
360        );
361
362        assert_eq!(prepared.previous_response_id.as_deref(), Some("resp_123"));
363        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
364        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
365        assert!(!prepared.clear_stale_chain);
366    }
367
368    #[test]
369    fn prepare_responses_continuation_request_ignores_chain_for_unsupported_provider() {
370        let messages = vec![Message::user("hello".to_string())];
371        let prepared = prepare_responses_continuation_request(
372            "local",
373            false,
374            &messages,
375            Some(&ResponsesContinuationState {
376                response_id: "resp_123".to_string(),
377                messages: messages.clone().into(),
378            }),
379        );
380
381        assert_eq!(prepared.previous_response_id, None);
382        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
383        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
384        assert!(!prepared.clear_stale_chain);
385    }
386
387    #[test]
388    fn prepare_responses_continuation_request_ignores_openai_stale_chain_without_retry_recovery() {
389        let messages = vec![Message::user("continue".to_string())];
390        let prepared = prepare_responses_continuation_request(
391            "openai",
392            false,
393            &messages,
394            Some(&ResponsesContinuationState {
395                response_id: "resp_123".to_string(),
396                messages: vec![Message::user("hello".to_string())].into(),
397            }),
398        );
399
400        assert_eq!(prepared.previous_response_id, None);
401        assert!(matches!(prepared.messages, Cow::Borrowed(_)));
402        assert_eq!(prepared.messages.as_ref(), messages.as_slice());
403        assert!(!prepared.clear_stale_chain);
404    }
405}