Skip to main content

vtcode_core/llm/provider/
responses_continuation.rs

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