Skip to main content

agentic_core/executor/modes/
conversation.rs

1//! Conversation storage handler — owns all conversation store operations.
2
3use 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/// Handles all conversation store operations: creation, rehydration, and persistence.
12#[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    /// Gets an existing conversation or creates one.
24    ///
25    /// Reads `conversation_id` from `ctx.original_request`.
26    ///
27    /// # Errors
28    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
29    /// disabled, or the database query fails.
30    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    /// Gets an existing conversation.
40    ///
41    /// Reads `conversation_id` from `ctx.original_request`.
42    ///
43    /// # Errors
44    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
45    /// disabled, the conversation does not exist, or the database query fails.
46    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    /// Creates a brand-new conversation with a freshly generated ID.
56    ///
57    /// # Errors
58    /// Returns `ExecutorError` if the store is disabled or the database query fails.
59    pub async fn create(&self) -> ExecutorResult<ConversationData> {
60        self.store.create().await.map_err(ExecutorError::Storage)
61    }
62
63    /// Loads all history items for the conversation referenced by the request.
64    ///
65    /// Reads `conversation_id` from `ctx.original_request`. Returns an empty vec
66    /// if the conversation exists but has no items yet.
67    ///
68    /// # Errors
69    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
70    /// disabled, or the database query fails.
71    pub async fn rehydrate(&self, ctx: &RequestContext) -> ExecutorResult<Vec<InOutItem>> {
72        Ok(self.rehydrate_snapshot(ctx).await?.items)
73    }
74
75    /// Loads the conversation's history items and storage version.
76    ///
77    /// Reads `conversation_id` from `ctx.original_request`.
78    ///
79    /// # Errors
80    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
81    /// disabled, or the database query fails.
82    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    /// Persists one conversation turn — only the new items from this turn.
95    ///
96    /// Takes `ctx` and `output_items` by value so fields can be moved directly
97    /// into [`ResponseMetadata`] without cloning. The store tracks sequence
98    /// numbers and appends, so prior history must not be re-inserted.
99    ///
100    /// # Errors
101    /// Returns `ExecutorError` if `conversation_id` is absent on the context,
102    /// the store is disabled, or the database operation fails.
103    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}