agentic_core/executor/modes/
conversation.rs1use crate::storage::{
4 ConversationData, ConversationSnapshot, ConversationStore, InOutItem, ResponseMetadata, StorageError,
5};
6use crate::types::io::OutputItem;
7
8use crate::executor::error::{ExecutorError, ExecutorResult};
9use crate::executor::request::RequestContext;
10
11#[derive(Clone, Debug)]
13pub struct ConversationHandler {
14 store: ConversationStore,
15}
16
17impl ConversationHandler {
18 #[must_use]
19 pub fn new(store: ConversationStore) -> Self {
20 Self { store }
21 }
22
23 pub async fn get_or_create(&self, ctx: &RequestContext) -> ExecutorResult<ConversationData> {
31 let conv_id = ctx
32 .original_request
33 .conversation_id
34 .as_deref()
35 .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for get_or_create".into()))?;
36 self.store.get_or_create(conv_id).await.map_err(ExecutorError::Storage)
37 }
38
39 pub async fn get(&self, ctx: &RequestContext) -> ExecutorResult<ConversationData> {
47 let conv_id = ctx
48 .original_request
49 .conversation_id
50 .as_deref()
51 .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for get".into()))?;
52 self.store.get(conv_id).await.map_err(ExecutorError::Storage)
53 }
54
55 pub async fn create(&self) -> ExecutorResult<ConversationData> {
60 self.store.create().await.map_err(ExecutorError::Storage)
61 }
62
63 pub async fn rehydrate(&self, ctx: &RequestContext) -> ExecutorResult<Vec<InOutItem>> {
72 Ok(self.rehydrate_snapshot(ctx).await?.items)
73 }
74
75 pub async fn rehydrate_snapshot(&self, ctx: &RequestContext) -> ExecutorResult<ConversationSnapshot> {
83 let conv_id = ctx
84 .original_request
85 .conversation_id
86 .as_deref()
87 .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for rehydrate".into()))?;
88 self.store
89 .rehydrate_snapshot(conv_id)
90 .await
91 .map_err(ExecutorError::Storage)
92 }
93
94 pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec<OutputItem>) -> ExecutorResult<()> {
104 let conversation_id = ctx
105 .conversation_id
106 .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for execute_turn".into()))?;
107 let conversation_version = ctx
108 .conversation_version
109 .ok_or_else(|| ExecutorError::InvalidRequest("conversation version is required for execute_turn".into()))?;
110
111 let metadata = ResponseMetadata {
112 model: ctx.enriched_request.model,
113 previous_response_id: ctx.original_request.previous_response_id,
114 effective_tools: ctx.enriched_request.tools,
115 effective_tool_choice: ctx.enriched_request.tool_choice.unwrap_or_default(),
116 effective_instructions: ctx.enriched_request.instructions,
117 };
118
119 let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len());
120 new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input));
121 new_items.extend(output_items.into_iter().map(InOutItem::Output));
122
123 self.store
124 .persist_if_version(
125 &conversation_id,
126 conversation_version,
127 &ctx.response_id,
128 metadata.previous_response_id.as_deref(),
129 new_items,
130 &metadata,
131 )
132 .await
133 .map_err(|error| match error {
134 source @ StorageError::ConversationConflict { .. } => ExecutorError::ConversationLocked { source },
135 other => ExecutorError::Storage(other),
136 })
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::storage::{ConversationVersion, create_pool_with_schema};
144 use crate::types::io::ResponsesInput;
145 use crate::types::request_response::RequestPayload;
146
147 fn disabled_handler() -> ConversationHandler {
148 ConversationHandler::new(ConversationStore::disabled())
149 }
150
151 fn make_ctx(conversation_id: Option<&str>) -> RequestContext {
152 let req = RequestPayload {
153 model: "test".into(),
154 input: ResponsesInput::Text("hi".into()),
155 instructions: None,
156 previous_response_id: None,
157 conversation_id: conversation_id.map(str::to_string),
158 tools: None,
159 tool_choice: None,
160 stream: false,
161 store: true,
162 include: None,
163 temperature: None,
164 top_p: None,
165 max_output_tokens: None,
166 truncation: None,
167 metadata: None,
168 parallel_tool_calls: None,
169 cache_salt: None,
170 context_management: None,
171 };
172 RequestContext {
173 enriched_request: req.clone(),
174 original_request: req,
175 new_input_items: vec![],
176 response_id: "resp_test".into(),
177 conversation_id: conversation_id.map(str::to_string),
178 conversation_version: None,
179 }
180 }
181
182 #[tokio::test]
183 async fn test_get_or_create_missing_id_returns_error() {
184 let result = disabled_handler().get_or_create(&make_ctx(None)).await;
185 assert!(result.is_err());
186 }
187
188 #[tokio::test]
189 async fn test_rehydrate_missing_id_returns_error() {
190 let result = disabled_handler().rehydrate(&make_ctx(None)).await;
191 assert!(result.is_err());
192 }
193
194 #[tokio::test]
195 async fn test_get_or_create_disabled_store_returns_error() {
196 let result = disabled_handler().get_or_create(&make_ctx(Some("conv_1"))).await;
197 assert!(result.is_err());
198 }
199
200 #[tokio::test]
201 async fn test_get_disabled_store_returns_error() {
202 let result = disabled_handler().get(&make_ctx(Some("conv_1"))).await;
203 assert!(result.is_err());
204 }
205
206 #[tokio::test]
207 async fn test_rehydrate_disabled_store_returns_error() {
208 let result = disabled_handler().rehydrate(&make_ctx(Some("conv_1"))).await;
209 assert!(result.is_err());
210 }
211
212 #[tokio::test]
213 async fn test_execute_turn_missing_conv_id_returns_error() {
214 let result = disabled_handler().execute_turn(make_ctx(None), vec![]).await;
215 assert!(result.is_err());
216 }
217
218 #[tokio::test]
219 async fn execute_turn_rejects_missing_conversation_version_without_writing()
220 -> Result<(), Box<dyn std::error::Error>> {
221 let pool = create_pool_with_schema(Some("sqlite://?mode=memory")).await?;
222 let store = ConversationStore::new(pool);
223 let conversation = store.create().await?;
224 let handler = ConversationHandler::new(store.clone());
225 let mut ctx = make_ctx(Some(&conversation.conversation_id));
226 ctx.new_input_items = Vec::from(&ctx.original_request.input);
227
228 let error = handler
229 .execute_turn(ctx, vec![])
230 .await
231 .expect_err("missing captured version must reject the turn");
232
233 assert!(matches!(
234 error,
235 ExecutorError::InvalidRequest(message)
236 if message == "conversation version is required for execute_turn"
237 ));
238 assert!(store.rehydrate(&conversation.conversation_id).await?.is_empty());
239 Ok(())
240 }
241
242 #[tokio::test]
243 async fn execute_turn_persists_with_captured_conversation_version() -> Result<(), Box<dyn std::error::Error>> {
244 let pool = create_pool_with_schema(Some("sqlite://?mode=memory")).await?;
245 let store = ConversationStore::new(pool);
246 let conversation = store.create().await?;
247 let handler = ConversationHandler::new(store.clone());
248 let mut ctx = make_ctx(Some(&conversation.conversation_id));
249 ctx.new_input_items = Vec::from(&ctx.original_request.input);
250 ctx.conversation_version = Some(ConversationVersion::Empty);
251
252 handler.execute_turn(ctx, vec![]).await?;
253
254 let snapshot = store.rehydrate_snapshot(&conversation.conversation_id).await?;
255 assert_eq!(snapshot.items.len(), 1);
256 assert_eq!(snapshot.version, ConversationVersion::LastSequence(0));
257 Ok(())
258 }
259
260 #[tokio::test]
261 async fn execute_turn_rejects_a_stale_captured_conversation_version() -> Result<(), Box<dyn std::error::Error>> {
262 use std::error::Error;
263
264 let pool = create_pool_with_schema(Some("sqlite://?mode=memory")).await?;
265 let store = ConversationStore::new(pool);
266 let conversation = store.create().await?;
267 let handler = ConversationHandler::new(store.clone());
268 let mut ctx = make_ctx(Some(&conversation.conversation_id));
269 ctx.new_input_items = Vec::from(&ctx.original_request.input);
270 ctx.conversation_version = Some(ConversationVersion::Empty);
271 let competing_items = Vec::from(&ResponsesInput::Text("competing input".into()))
272 .into_iter()
273 .map(InOutItem::Input)
274 .collect();
275 store
276 .persist(
277 &conversation.conversation_id,
278 "resp_competing",
279 None,
280 competing_items,
281 &ResponseMetadata::default(),
282 )
283 .await?;
284
285 let error = handler
286 .execute_turn(ctx, vec![])
287 .await
288 .expect_err("stale captured version must reject the turn");
289
290 let source = error.source().expect("conversation conflict source must be retained");
291 assert!(matches!(
292 source.downcast_ref::<StorageError>(),
293 Some(StorageError::ConversationConflict { conversation_id })
294 if conversation_id == &conversation.conversation_id
295 ));
296 assert!(matches!(error, ExecutorError::ConversationLocked { .. }));
297 Ok(())
298 }
299}