1use std::path::Path;
55use std::sync::{Arc, Mutex};
56
57use agent_sdk_foundation::events::AgentEventEnvelope;
58use agent_sdk_foundation::llm;
59use agent_sdk_foundation::types::{AgentState, ThreadId, ToolExecution};
60use anyhow::{Context, Result};
61use async_trait::async_trait;
62use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
63
64use super::{EventStore, MessageStore, StateStore, StoredTurnEvents, ToolExecutionStore};
65
66const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
71
72const SCHEMA_VERSION: i64 = 1;
75
76const SCHEMA: &str = "\
79CREATE TABLE IF NOT EXISTS agent_messages (
80 id INTEGER PRIMARY KEY AUTOINCREMENT,
81 thread_id TEXT NOT NULL,
82 payload TEXT NOT NULL
83);
84CREATE INDEX IF NOT EXISTS idx_agent_messages_thread
85 ON agent_messages (thread_id, id);
86
87CREATE TABLE IF NOT EXISTS agent_states (
88 thread_id TEXT PRIMARY KEY,
89 payload TEXT NOT NULL
90);
91
92CREATE TABLE IF NOT EXISTS agent_event_turns (
93 thread_id TEXT NOT NULL,
94 turn INTEGER NOT NULL,
95 finished INTEGER NOT NULL DEFAULT 0,
96 PRIMARY KEY (thread_id, turn)
97);
98
99CREATE TABLE IF NOT EXISTS agent_events (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 thread_id TEXT NOT NULL,
102 turn INTEGER NOT NULL,
103 payload TEXT NOT NULL
104);
105CREATE INDEX IF NOT EXISTS idx_agent_events_thread_turn
106 ON agent_events (thread_id, turn, id);
107
108CREATE TABLE IF NOT EXISTS agent_tool_executions (
109 tool_call_id TEXT PRIMARY KEY,
110 operation_id TEXT,
111 payload TEXT NOT NULL
112);
113CREATE INDEX IF NOT EXISTS idx_agent_tool_executions_operation
114 ON agent_tool_executions (operation_id);
115";
116
117#[derive(Clone)]
124pub struct SqliteStore {
125 conn: Arc<Mutex<Connection>>,
126}
127
128impl SqliteStore {
129 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
146 let path = path.as_ref();
147 let conn = Connection::open(path)
148 .with_context(|| format!("failed to open sqlite database at {}", path.display()))?;
149 conn.busy_timeout(BUSY_TIMEOUT)
156 .context("failed to set sqlite busy timeout")?;
157 conn.pragma_update(None, "journal_mode", "WAL")
159 .context("failed to enable WAL journal mode")?;
160 let version: i64 = conn
166 .pragma_query_value(None, "user_version", |row| row.get(0))
167 .context("failed to read sqlite user_version")?;
168 if version > SCHEMA_VERSION {
169 anyhow::bail!(
170 "sqlite store at {} has schema version {version}, newer than the \
171 version {SCHEMA_VERSION} this SDK supports; upgrade the SDK (or point at a \
172 different file)",
173 path.display()
174 );
175 }
176 conn.execute_batch(SCHEMA)
177 .context("failed to initialize sqlite store schema")?;
178 if version == 0 {
179 conn.pragma_update(None, "user_version", SCHEMA_VERSION)
180 .context("failed to stamp sqlite user_version")?;
181 }
182 Ok(Self {
183 conn: Arc::new(Mutex::new(conn)),
184 })
185 }
186
187 async fn with_conn<T>(
193 &self,
194 f: impl FnOnce(&mut Connection) -> Result<T> + Send + 'static,
195 ) -> Result<T>
196 where
197 T: Send + 'static,
198 {
199 let conn = Arc::clone(&self.conn);
200 tokio::task::spawn_blocking(move || {
201 let mut guard = conn
202 .lock()
203 .ok()
204 .context("sqlite connection mutex poisoned")?;
205 f(&mut guard)
206 })
207 .await
208 .context("sqlite blocking task failed to join")?
209 }
210
211 async fn upsert_execution(&self, execution: ToolExecution) -> Result<()> {
215 let tool_call_id = execution.tool_call_id.clone();
216 let operation_id = execution.operation_id.clone();
217 let payload =
218 serde_json::to_string(&execution).context("failed to encode tool execution")?;
219 self.with_conn(move |conn| {
220 conn.execute(
221 "INSERT INTO agent_tool_executions (tool_call_id, operation_id, payload)
222 VALUES (?1, ?2, ?3)
223 ON CONFLICT(tool_call_id)
224 DO UPDATE SET operation_id = excluded.operation_id, payload = excluded.payload",
225 params![tool_call_id, operation_id, payload],
226 )
227 .context("failed to upsert tool execution")?;
228 Ok(())
229 })
230 .await
231 }
232}
233
234#[async_trait]
235impl MessageStore for SqliteStore {
236 async fn append(&self, thread_id: &ThreadId, message: llm::Message) -> Result<()> {
237 let thread = thread_id.0.clone();
238 let payload = serde_json::to_string(&message).context("failed to encode message")?;
239 self.with_conn(move |conn| {
240 conn.execute(
241 "INSERT INTO agent_messages (thread_id, payload) VALUES (?1, ?2)",
242 params![thread, payload],
243 )
244 .context("failed to append message")?;
245 Ok(())
246 })
247 .await
248 }
249
250 async fn get_history(&self, thread_id: &ThreadId) -> Result<Vec<llm::Message>> {
251 let thread = thread_id.0.clone();
252 self.with_conn(move |conn| {
253 let mut stmt = conn
254 .prepare("SELECT payload FROM agent_messages WHERE thread_id = ?1 ORDER BY id")
255 .context("failed to prepare message history query")?;
256 let rows = stmt
257 .query_map(params![thread], |row| row.get::<_, String>(0))
258 .context("failed to read message history")?;
259 let mut messages = Vec::new();
260 for row in rows {
261 let payload = row.context("failed to read message row")?;
262 messages.push(serde_json::from_str(&payload).context("failed to decode message")?);
263 }
264 Ok(messages)
265 })
266 .await
267 }
268
269 async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
270 let thread = thread_id.0.clone();
271 self.with_conn(move |conn| {
272 conn.execute(
273 "DELETE FROM agent_messages WHERE thread_id = ?1",
274 params![thread],
275 )
276 .context("failed to clear messages")?;
277 Ok(())
278 })
279 .await
280 }
281
282 async fn count(&self, thread_id: &ThreadId) -> Result<usize> {
283 let thread = thread_id.0.clone();
284 self.with_conn(move |conn| {
285 let count: i64 = conn
286 .query_row(
287 "SELECT COUNT(*) FROM agent_messages WHERE thread_id = ?1",
288 params![thread],
289 |row| row.get(0),
290 )
291 .context("failed to count messages")?;
292 usize::try_from(count).context("message count is negative")
293 })
294 .await
295 }
296
297 async fn replace_history(
298 &self,
299 thread_id: &ThreadId,
300 messages: Vec<llm::Message>,
301 ) -> Result<()> {
302 let thread = thread_id.0.clone();
303 let payloads = messages
304 .iter()
305 .map(|message| serde_json::to_string(message).context("failed to encode message"))
306 .collect::<Result<Vec<_>>>()?;
307 self.with_conn(move |conn| {
308 let tx = conn
309 .transaction()
310 .context("failed to begin replace_history transaction")?;
311 tx.execute(
312 "DELETE FROM agent_messages WHERE thread_id = ?1",
313 params![thread],
314 )
315 .context("failed to clear existing messages")?;
316 for payload in payloads {
317 tx.execute(
318 "INSERT INTO agent_messages (thread_id, payload) VALUES (?1, ?2)",
319 params![thread, payload],
320 )
321 .context("failed to insert replacement message")?;
322 }
323 tx.commit()
324 .context("failed to commit replace_history transaction")?;
325 Ok(())
326 })
327 .await
328 }
329}
330
331#[async_trait]
332impl StateStore for SqliteStore {
333 async fn save(&self, state: &AgentState) -> Result<()> {
334 let thread = state.thread_id.0.clone();
335 let payload = serde_json::to_string(state).context("failed to encode agent state")?;
336 self.with_conn(move |conn| {
337 conn.execute(
338 "INSERT INTO agent_states (thread_id, payload) VALUES (?1, ?2)
339 ON CONFLICT(thread_id) DO UPDATE SET payload = excluded.payload",
340 params![thread, payload],
341 )
342 .context("failed to save agent state")?;
343 Ok(())
344 })
345 .await
346 }
347
348 async fn load(&self, thread_id: &ThreadId) -> Result<Option<AgentState>> {
349 let thread = thread_id.0.clone();
350 self.with_conn(move |conn| {
351 let payload: Option<String> = conn
352 .query_row(
353 "SELECT payload FROM agent_states WHERE thread_id = ?1",
354 params![thread],
355 |row| row.get(0),
356 )
357 .optional()
358 .context("failed to load agent state")?;
359 match payload {
360 Some(payload) => Ok(Some(
361 serde_json::from_str(&payload).context("failed to decode agent state")?,
362 )),
363 None => Ok(None),
364 }
365 })
366 .await
367 }
368
369 async fn delete(&self, thread_id: &ThreadId) -> Result<()> {
370 let thread = thread_id.0.clone();
371 self.with_conn(move |conn| {
372 conn.execute(
373 "DELETE FROM agent_states WHERE thread_id = ?1",
374 params![thread],
375 )
376 .context("failed to delete agent state")?;
377 Ok(())
378 })
379 .await
380 }
381}
382
383#[async_trait]
384impl EventStore for SqliteStore {
385 async fn append(
386 &self,
387 thread_id: &ThreadId,
388 turn: usize,
389 envelope: AgentEventEnvelope,
390 ) -> Result<()> {
391 let thread = thread_id.0.clone();
392 let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
393 let payload = serde_json::to_string(&envelope).context("failed to encode event")?;
394 self.with_conn(move |conn| {
395 let tx = conn
400 .transaction_with_behavior(TransactionBehavior::Immediate)
401 .context("failed to begin append transaction")?;
402 let finished: Option<i64> = tx
403 .query_row(
404 "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
405 params![thread, turn_i64],
406 |row| row.get(0),
407 )
408 .optional()
409 .context("failed to read turn state")?;
410 anyhow::ensure!(finished != Some(1), "cannot append to finished turn {turn}");
411 tx.execute(
412 "INSERT OR IGNORE INTO agent_event_turns (thread_id, turn, finished)
413 VALUES (?1, ?2, 0)",
414 params![thread, turn_i64],
415 )
416 .context("failed to record turn")?;
417 tx.execute(
418 "INSERT INTO agent_events (thread_id, turn, payload) VALUES (?1, ?2, ?3)",
419 params![thread, turn_i64, payload],
420 )
421 .context("failed to append event")?;
422 tx.commit().context("failed to commit append transaction")?;
423 Ok(())
424 })
425 .await
426 }
427
428 async fn finish_turn(&self, thread_id: &ThreadId, turn: usize) -> Result<()> {
429 let thread = thread_id.0.clone();
430 let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
431 self.with_conn(move |conn| {
432 let tx = conn
436 .transaction_with_behavior(TransactionBehavior::Immediate)
437 .context("failed to begin finish_turn transaction")?;
438 let finished: Option<i64> = tx
439 .query_row(
440 "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
441 params![thread, turn_i64],
442 |row| row.get(0),
443 )
444 .optional()
445 .context("failed to read turn state")?;
446 anyhow::ensure!(finished != Some(1), "turn {turn} is already finished");
447 tx.execute(
448 "INSERT INTO agent_event_turns (thread_id, turn, finished) VALUES (?1, ?2, 1)
449 ON CONFLICT(thread_id, turn) DO UPDATE SET finished = 1",
450 params![thread, turn_i64],
451 )
452 .context("failed to finish turn")?;
453 tx.commit()
454 .context("failed to commit finish_turn transaction")?;
455 Ok(())
456 })
457 .await
458 }
459
460 async fn get_turn(
461 &self,
462 thread_id: &ThreadId,
463 turn: usize,
464 ) -> Result<Option<StoredTurnEvents>> {
465 let thread = thread_id.0.clone();
466 let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
467 self.with_conn(move |conn| {
468 let tx = conn
473 .transaction()
474 .context("failed to begin turn read transaction")?;
475 let finished: Option<i64> = tx
476 .query_row(
477 "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
478 params![thread, turn_i64],
479 |row| row.get(0),
480 )
481 .optional()
482 .context("failed to read turn state")?;
483 let Some(finished) = finished else {
484 return Ok(None);
485 };
486 let events = {
487 let mut stmt = tx
488 .prepare(
489 "SELECT payload FROM agent_events
490 WHERE thread_id = ?1 AND turn = ?2 ORDER BY id",
491 )
492 .context("failed to prepare turn events query")?;
493 let rows = stmt
494 .query_map(params![thread, turn_i64], |row| row.get::<_, String>(0))
495 .context("failed to read turn events")?;
496 let mut events = Vec::new();
497 for row in rows {
498 let payload = row.context("failed to read event row")?;
499 events.push(serde_json::from_str(&payload).context("failed to decode event")?);
500 }
501 events
502 };
503 tx.commit().context("failed to end turn read transaction")?;
504 Ok(Some(StoredTurnEvents {
505 turn,
506 events,
507 finished: finished != 0,
508 }))
509 })
510 .await
511 }
512
513 async fn get_turns(&self, thread_id: &ThreadId) -> Result<Vec<StoredTurnEvents>> {
514 let thread = thread_id.0.clone();
515 self.with_conn(move |conn| {
516 let tx = conn
521 .transaction()
522 .context("failed to begin turns read transaction")?;
523 let mut turns: Vec<StoredTurnEvents> = Vec::new();
524 {
525 let mut turn_stmt = tx
526 .prepare(
527 "SELECT turn, finished FROM agent_event_turns
528 WHERE thread_id = ?1 ORDER BY turn",
529 )
530 .context("failed to prepare turns query")?;
531 let turn_rows = turn_stmt
532 .query_map(params![thread], |row| {
533 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
534 })
535 .context("failed to read turns")?;
536 let mut position = std::collections::HashMap::new();
537 for row in turn_rows {
538 let (turn_i64, finished) = row.context("failed to read turn row")?;
539 let turn = usize::try_from(turn_i64).context("turn index is negative")?;
540 position.insert(turn_i64, turns.len());
541 turns.push(StoredTurnEvents {
542 turn,
543 events: Vec::new(),
544 finished: finished != 0,
545 });
546 }
547 let mut event_stmt = tx
548 .prepare(
549 "SELECT turn, payload FROM agent_events
550 WHERE thread_id = ?1 ORDER BY turn, id",
551 )
552 .context("failed to prepare events query")?;
553 let event_rows = event_stmt
554 .query_map(params![thread], |row| {
555 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
556 })
557 .context("failed to read events")?;
558 for row in event_rows {
559 let (turn_i64, payload) = row.context("failed to read event row")?;
560 let envelope =
561 serde_json::from_str(&payload).context("failed to decode event")?;
562 if let Some(&index) = position.get(&turn_i64) {
563 turns[index].events.push(envelope);
564 }
565 }
566 }
567 tx.commit()
568 .context("failed to end turns read transaction")?;
569 Ok(turns)
570 })
571 .await
572 }
573
574 async fn get_events(&self, thread_id: &ThreadId) -> Result<Vec<AgentEventEnvelope>> {
575 let thread = thread_id.0.clone();
576 self.with_conn(move |conn| {
577 let mut stmt = conn
578 .prepare("SELECT payload FROM agent_events WHERE thread_id = ?1 ORDER BY turn, id")
579 .context("failed to prepare events query")?;
580 let rows = stmt
581 .query_map(params![thread], |row| row.get::<_, String>(0))
582 .context("failed to read events")?;
583 let mut events = Vec::new();
584 for row in rows {
585 let payload = row.context("failed to read event row")?;
586 events.push(serde_json::from_str(&payload).context("failed to decode event")?);
587 }
588 Ok(events)
589 })
590 .await
591 }
592
593 async fn event_count(&self, thread_id: &ThreadId) -> Result<usize> {
594 let thread = thread_id.0.clone();
595 self.with_conn(move |conn| {
596 let count: i64 = conn
597 .query_row(
598 "SELECT COUNT(*) FROM agent_events WHERE thread_id = ?1",
599 params![thread],
600 |row| row.get(0),
601 )
602 .context("failed to count events")?;
603 usize::try_from(count).context("event count is negative")
604 })
605 .await
606 }
607
608 async fn get_events_since(
609 &self,
610 thread_id: &ThreadId,
611 offset: usize,
612 ) -> Result<Vec<AgentEventEnvelope>> {
613 let thread = thread_id.0.clone();
614 let offset_i64 = i64::try_from(offset).context("offset exceeds i64 range")?;
615 self.with_conn(move |conn| {
616 let mut stmt = conn
617 .prepare(
618 "SELECT payload FROM agent_events WHERE thread_id = ?1
619 ORDER BY turn, id LIMIT -1 OFFSET ?2",
620 )
621 .context("failed to prepare events query")?;
622 let rows = stmt
623 .query_map(params![thread, offset_i64], |row| row.get::<_, String>(0))
624 .context("failed to read events")?;
625 let mut events = Vec::new();
626 for row in rows {
627 let payload = row.context("failed to read event row")?;
628 events.push(serde_json::from_str(&payload).context("failed to decode event")?);
629 }
630 Ok(events)
631 })
632 .await
633 }
634
635 async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
636 let thread = thread_id.0.clone();
637 self.with_conn(move |conn| {
638 let tx = conn
639 .transaction()
640 .context("failed to begin event clear transaction")?;
641 tx.execute(
642 "DELETE FROM agent_events WHERE thread_id = ?1",
643 params![thread],
644 )
645 .context("failed to clear events")?;
646 tx.execute(
647 "DELETE FROM agent_event_turns WHERE thread_id = ?1",
648 params![thread],
649 )
650 .context("failed to clear turns")?;
651 tx.commit()
652 .context("failed to commit event clear transaction")?;
653 Ok(())
654 })
655 .await
656 }
657}
658
659#[async_trait]
660impl ToolExecutionStore for SqliteStore {
661 async fn get_execution(&self, tool_call_id: &str) -> Result<Option<ToolExecution>> {
662 let id = tool_call_id.to_owned();
663 self.with_conn(move |conn| {
664 let payload: Option<String> = conn
665 .query_row(
666 "SELECT payload FROM agent_tool_executions WHERE tool_call_id = ?1",
667 params![id],
668 |row| row.get(0),
669 )
670 .optional()
671 .context("failed to read tool execution")?;
672 match payload {
673 Some(payload) => Ok(Some(
674 serde_json::from_str(&payload).context("failed to decode tool execution")?,
675 )),
676 None => Ok(None),
677 }
678 })
679 .await
680 }
681
682 async fn record_execution(&self, execution: ToolExecution) -> Result<()> {
683 self.upsert_execution(execution).await
684 }
685
686 async fn update_execution(&self, execution: ToolExecution) -> Result<()> {
687 self.upsert_execution(execution).await
688 }
689
690 async fn get_execution_by_operation_id(
691 &self,
692 operation_id: &str,
693 ) -> Result<Option<ToolExecution>> {
694 let id = operation_id.to_owned();
695 self.with_conn(move |conn| {
696 let payload: Option<String> = conn
703 .query_row(
704 "SELECT payload FROM agent_tool_executions WHERE operation_id = ?1
705 ORDER BY rowid DESC LIMIT 1",
706 params![id],
707 |row| row.get(0),
708 )
709 .optional()
710 .context("failed to read tool execution by operation id")?;
711 match payload {
712 Some(payload) => Ok(Some(
713 serde_json::from_str(&payload).context("failed to decode tool execution")?,
714 )),
715 None => Ok(None),
716 }
717 })
718 .await
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use agent_sdk_foundation::events::{AgentEvent, AgentEventEnvelope, SequenceCounter};
726 use agent_sdk_foundation::llm::Message;
727 use agent_sdk_foundation::types::ToolResult;
728 use anyhow::bail;
729 use tempfile::tempdir;
730
731 #[tokio::test]
734 async fn persists_all_stores_across_reopen() -> Result<()> {
735 let dir = tempdir()?;
736 let path = dir.path().join("agent.db");
737 let thread_id = ThreadId::new();
738 let seq = SequenceCounter::new();
739
740 {
742 let store = SqliteStore::open(&path)?;
743
744 MessageStore::append(&store, &thread_id, Message::user("hello")).await?;
745 MessageStore::append(&store, &thread_id, Message::assistant("hi there")).await?;
746
747 let state = AgentState::new(thread_id.clone());
748 store.save(&state).await?;
749
750 EventStore::append(
751 &store,
752 &thread_id,
753 1,
754 AgentEventEnvelope::wrap(AgentEvent::text("m1", "streamed"), &seq),
755 )
756 .await?;
757 store.finish_turn(&thread_id, 1).await?;
758
759 let execution = ToolExecution::new_in_flight(
760 "call_1",
761 thread_id.clone(),
762 "my_tool",
763 "My Tool",
764 serde_json::json!({"k": "v"}),
765 time::OffsetDateTime::now_utc(),
766 );
767 store.record_execution(execution).await?;
768 }
769
770 let store = SqliteStore::open(&path)?;
772
773 let history = store.get_history(&thread_id).await?;
774 assert_eq!(history.len(), 2, "messages must survive reopen");
775 assert_eq!(MessageStore::count(&store, &thread_id).await?, 2);
776
777 let loaded = store.load(&thread_id).await?;
778 assert!(loaded.is_some(), "state must survive reopen");
779 assert_eq!(
780 loaded.context("state present")?.thread_id,
781 thread_id,
782 "loaded state must match the thread"
783 );
784
785 let events = store.get_events(&thread_id).await?;
786 assert_eq!(events.len(), 1, "events must survive reopen");
787 assert_eq!(store.event_count(&thread_id).await?, 1);
788 let turn = store
789 .get_turn(&thread_id, 1)
790 .await?
791 .context("turn 1 present")?;
792 assert!(turn.finished, "finish barrier must survive reopen");
793
794 let execution = store
795 .get_execution("call_1")
796 .await?
797 .context("execution present")?;
798 assert_eq!(execution.tool_name, "my_tool");
799 assert!(execution.is_in_flight());
800
801 Ok(())
802 }
803
804 #[tokio::test]
806 async fn threads_are_isolated_in_one_file() -> Result<()> {
807 let dir = tempdir()?;
808 let path = dir.path().join("agent.db");
809 let store = SqliteStore::open(&path)?;
810
811 let thread_a = ThreadId::new();
812 let thread_b = ThreadId::new();
813
814 MessageStore::append(&store, &thread_a, Message::user("a-only")).await?;
815
816 assert_eq!(store.get_history(&thread_a).await?.len(), 1);
817 assert!(
818 store.get_history(&thread_b).await?.is_empty(),
819 "a fresh thread starts empty in a shared file"
820 );
821 Ok(())
822 }
823
824 #[tokio::test]
827 async fn rejects_append_after_finish_across_reopen() -> Result<()> {
828 let dir = tempdir()?;
829 let path = dir.path().join("agent.db");
830 let thread_id = ThreadId::new();
831 let seq = SequenceCounter::new();
832
833 {
834 let store = SqliteStore::open(&path)?;
835 store.finish_turn(&thread_id, 1).await?;
836 }
837
838 let store = SqliteStore::open(&path)?;
839 let Err(error) = EventStore::append(
840 &store,
841 &thread_id,
842 1,
843 AgentEventEnvelope::wrap(AgentEvent::text("late", "late"), &seq),
844 )
845 .await
846 else {
847 bail!("append after finish must fail");
848 };
849 assert!(error.to_string().contains("cannot append to finished turn"));
850 Ok(())
851 }
852
853 #[tokio::test]
857 async fn rejects_newer_schema_version() -> Result<()> {
858 let dir = tempdir()?;
859 let path = dir.path().join("agent.db");
860
861 {
862 let _store = SqliteStore::open(&path)?;
863 }
864 {
866 let conn = Connection::open(&path)?;
867 conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)?;
868 }
869
870 let Err(error) = SqliteStore::open(&path) else {
871 bail!("opening a newer-versioned database must fail");
872 };
873 assert!(
874 error.to_string().contains("newer than the"),
875 "unexpected error: {error}"
876 );
877 Ok(())
878 }
879
880 #[tokio::test]
884 async fn rejects_append_after_finish_in_process() -> Result<()> {
885 let dir = tempdir()?;
886 let path = dir.path().join("agent.db");
887 let thread_id = ThreadId::new();
888 let seq = SequenceCounter::new();
889
890 let store = SqliteStore::open(&path)?;
891 EventStore::append(
892 &store,
893 &thread_id,
894 1,
895 AgentEventEnvelope::wrap(AgentEvent::text("m1", "early"), &seq),
896 )
897 .await?;
898 store.finish_turn(&thread_id, 1).await?;
899
900 let Err(error) = EventStore::append(
901 &store,
902 &thread_id,
903 1,
904 AgentEventEnvelope::wrap(AgentEvent::text("late", "late"), &seq),
905 )
906 .await
907 else {
908 bail!("append after finish must fail");
909 };
910 assert!(error.to_string().contains("cannot append to finished turn"));
911
912 EventStore::append(
914 &store,
915 &thread_id,
916 2,
917 AgentEventEnvelope::wrap(AgentEvent::text("m2", "ok"), &seq),
918 )
919 .await?;
920 assert_eq!(store.event_count(&thread_id).await?, 2);
921 Ok(())
922 }
923
924 #[tokio::test]
928 async fn operation_id_lookup_is_latest_wins() -> Result<()> {
929 let dir = tempdir()?;
930 let path = dir.path().join("agent.db");
931 let store = SqliteStore::open(&path)?;
932 let thread_id = ThreadId::new();
933
934 let mut first = ToolExecution::new_in_flight(
935 "call_first",
936 thread_id.clone(),
937 "tool",
938 "Tool",
939 serde_json::json!({}),
940 time::OffsetDateTime::now_utc(),
941 );
942 first.set_operation_id("op_shared");
943 store.record_execution(first).await?;
944
945 let mut second = ToolExecution::new_in_flight(
946 "call_second",
947 thread_id,
948 "tool",
949 "Tool",
950 serde_json::json!({}),
951 time::OffsetDateTime::now_utc(),
952 );
953 second.set_operation_id("op_shared");
954 store.record_execution(second).await?;
955
956 assert_eq!(
957 store
958 .get_execution_by_operation_id("op_shared")
959 .await?
960 .context("op_shared resolves")?
961 .tool_call_id,
962 "call_second",
963 "the most recently written execution must win"
964 );
965 Ok(())
966 }
967
968 #[tokio::test]
970 async fn operation_id_lookup_and_supersession() -> Result<()> {
971 let dir = tempdir()?;
972 let path = dir.path().join("agent.db");
973 let store = SqliteStore::open(&path)?;
974 let thread_id = ThreadId::new();
975
976 let mut execution = ToolExecution::new_in_flight(
977 "call_op",
978 thread_id,
979 "async_tool",
980 "Async Tool",
981 serde_json::json!({}),
982 time::OffsetDateTime::now_utc(),
983 );
984 execution.set_operation_id("op_old");
985 store.record_execution(execution.clone()).await?;
986 assert_eq!(
987 store
988 .get_execution_by_operation_id("op_old")
989 .await?
990 .context("op_old resolves")?
991 .tool_call_id,
992 "call_op"
993 );
994
995 execution.set_operation_id("op_new");
996 store.update_execution(execution).await?;
997 assert!(
998 store
999 .get_execution_by_operation_id("op_old")
1000 .await?
1001 .is_none(),
1002 "superseded operation id must stop resolving"
1003 );
1004 assert_eq!(
1005 store
1006 .get_execution_by_operation_id("op_new")
1007 .await?
1008 .context("op_new resolves")?
1009 .tool_call_id,
1010 "call_op"
1011 );
1012
1013 let mut completed = store
1015 .get_execution("call_op")
1016 .await?
1017 .context("execution present")?;
1018 completed.complete(ToolResult::success("done"));
1019 store.update_execution(completed).await?;
1020 let reloaded = store
1021 .get_execution("call_op")
1022 .await?
1023 .context("execution present")?;
1024 assert!(reloaded.is_completed());
1025 Ok(())
1026 }
1027}