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 pub messages: Arc<Vec<M>>,
12}
13
14#[derive(Debug, Default, Clone)]
19pub struct ResponsesContinuationStore {
20 chains: HashMap<(String, String), ResponsesContinuationState>,
21}
22
23impl ResponsesContinuationStore {
24 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 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 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 pub fn clear(&mut self) {
52 self.chains.clear();
53 }
54
55 pub fn is_empty(&self) -> bool {
57 self.chains.is_empty()
58 }
59
60 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
119pub 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 assert!(store.get("openai", "gpt-5.6-sol").is_some());
212 assert!(store.get("OPENAI", "gpt-5.6-sol").is_some());
213
214 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", ¤t);
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 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", ¤t);
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}