diff --git a/src/harness/cursor_desktop.rs b/src/harness/cursor_desktop.rs index ec2e81e..d151537 100644 --- a/src/harness/cursor_desktop.rs +++ b/src/harness/cursor_desktop.rs @@ -1281,22 +1281,27 @@ impl Store for CursorDesktopStore { fn delete(&self, reference: &String) -> Result<()> { super::checked_id_component(CursorDesktop::NAME, reference)?; let conn = Connection::open(self.db_path()).map_err(sqlite_err)?; - let deleted: usize = conn - .execute( + let deleted_headers = if has_table(&conn, "composerHeaders")? { + conn.execute( "DELETE FROM composerHeaders WHERE composerId = ?1", params![reference], ) + .map_err(sqlite_err)? + } else { + 0 + }; + let deleted_kv: usize = conn + .execute( + "DELETE FROM cursorDiskKV WHERE key = ?1 OR key LIKE ?2 OR key LIKE ?3 OR key LIKE ?4", + params![ + format!("composerData:{reference}"), + format!("bubbleId:{reference}:%"), + format!("%:{reference}"), + format!("%:{reference}:%"), + ], + ) .map_err(sqlite_err)?; - conn.execute( - "DELETE FROM cursorDiskKV WHERE key = ?1 OR key LIKE ?2 OR key LIKE ?3", - params![ - format!("composerData:{reference}"), - format!("bubbleId:{reference}:%"), - format!("%:{reference}%"), - ], - ) - .map_err(sqlite_err)?; - if deleted == 0 { + if deleted_headers == 0 && deleted_kv == 0 { return Err(Error::Malformed { harness: CursorDesktop::NAME, detail: format!("no such session: {reference}"), diff --git a/tests/integration/cursor_desktop.rs b/tests/integration/cursor_desktop.rs index 8547b1f..c56ec3a 100644 --- a/tests/integration/cursor_desktop.rs +++ b/tests/integration/cursor_desktop.rs @@ -549,6 +549,20 @@ fn pending_tool_call_yields_no_result_message() { ); } +#[cfg(feature = "opencode")] +#[test] +fn delete_roundtrip_removes_session_and_fails_on_duplicate() { + let dir = tempfile::tempdir().unwrap(); + let store = CursorDesktopStore::new(dir.path()); + let saved = store.save(&sample_transcript()).unwrap(); + + assert_eq!(store.discover().unwrap().len(), 1); + store.delete(&saved.reference).unwrap(); + assert_eq!(store.discover().unwrap().len(), 0); + + assert!(store.delete(&saved.reference).is_err()); +} + #[test] fn edit_tool_with_replace_all_survives_fixpoint() { let mut common = fixpoint_common(); @@ -674,6 +688,121 @@ fn edit_and_write_tools_normalize_and_denormalize_losslessly() { assert_eq!(back.body, common.body); } +#[cfg(feature = "opencode")] +#[test] +fn delete_succeeds_on_database_without_composer_headers_table() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cursorDiskKV VALUES ('composerData:pre-table-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:pre-table-1:b1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"pre-table-1".to_string()).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%pre-table-1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + + // Deleting again should fail with no such session + assert!(store.delete(&"pre-table-1".to_string()).is_err()); +} + +#[cfg(feature = "opencode")] +#[test] +fn delete_succeeds_on_kv_only_session_in_modern_database() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, recency INTEGER); + CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cursorDiskKV VALUES ('composerData:kv-only-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:kv-only-1:b1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"kv-only-1".to_string()).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%kv-only-1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + + assert!(store.delete(&"kv-only-1".to_string()).is_err()); +} + +#[cfg(feature = "opencode")] +#[test] +fn delete_exact_session_id_does_not_delete_prefixed_sibling() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, recency INTEGER); + CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO composerHeaders VALUES ('session-1', 100); + INSERT INTO composerHeaders VALUES ('session-10', 200); + INSERT INTO cursorDiskKV VALUES ('composerData:session-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session-1:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('checkpointId:session-1:c1', '{}'); + INSERT INTO cursorDiskKV VALUES ('composerData:session-10', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session-10:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('checkpointId:session-10:c1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"session-1".to_string()).unwrap(); + + let count_s10_headers: i64 = conn + .query_row( + "SELECT count(*) FROM composerHeaders WHERE composerId = 'session-10'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_s10_headers, 1); + + let remaining_s10_keys: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session-10%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(remaining_s10_keys, 3); + + let remaining_target_keys: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session-1%' AND key NOT LIKE '%session-10%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(remaining_target_keys, 0); +} + #[cfg(feature = "opencode")] #[test] fn store_resolves_workspace_id_with_percent_encoding_and_spaces() {