thread_metadata_store.rs

  1use std::{path::Path, sync::Arc};
  2
  3use acp_thread::AgentSessionInfo;
  4use agent::{ThreadStore, ZED_AGENT_ID};
  5use agent_client_protocol as acp;
  6use anyhow::{Context as _, Result};
  7use chrono::{DateTime, Utc};
  8use collections::HashMap;
  9use db::{
 10    sqlez::{
 11        bindable::Column, domain::Domain, statement::Statement,
 12        thread_safe_connection::ThreadSafeConnection,
 13    },
 14    sqlez_macros::sql,
 15};
 16use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 17use futures::{FutureExt as _, future::Shared};
 18use gpui::{AppContext as _, Entity, Global, Subscription, Task};
 19use project::AgentId;
 20use ui::{App, Context, SharedString};
 21use util::ResultExt as _;
 22use workspace::PathList;
 23
 24pub fn init(cx: &mut App) {
 25    SidebarThreadMetadataStore::init_global(cx);
 26
 27    if cx.has_flag::<AgentV2FeatureFlag>() {
 28        migrate_thread_metadata(cx);
 29    }
 30    cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
 31        if has_flag {
 32            migrate_thread_metadata(cx);
 33        }
 34    })
 35    .detach();
 36}
 37
 38/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
 39///
 40/// TODO: Remove this after N weeks of shipping the sidebar
 41fn migrate_thread_metadata(cx: &mut App) {
 42    let store = SidebarThreadMetadataStore::global(cx);
 43    let db = store.read(cx).db.clone();
 44
 45    cx.spawn(async move |cx| {
 46        if !db.is_empty()? {
 47            return Ok::<(), anyhow::Error>(());
 48        }
 49
 50        let metadata = store.read_with(cx, |_store, app| {
 51            ThreadStore::global(app)
 52                .read(app)
 53                .entries()
 54                .map(|entry| ThreadMetadata {
 55                    session_id: entry.id,
 56                    agent_id: None,
 57                    title: entry.title,
 58                    updated_at: entry.updated_at,
 59                    created_at: entry.created_at,
 60                    folder_paths: entry.folder_paths,
 61                })
 62                .collect::<Vec<_>>()
 63        });
 64
 65        // Manually save each entry to the database and call reload, otherwise
 66        // we'll end up triggering lots of reloads after each save
 67        for entry in metadata {
 68            db.save(entry).await?;
 69        }
 70
 71        let _ = store.update(cx, |store, cx| store.reload(cx));
 72        Ok(())
 73    })
 74    .detach_and_log_err(cx);
 75}
 76
 77struct GlobalThreadMetadataStore(Entity<SidebarThreadMetadataStore>);
 78impl Global for GlobalThreadMetadataStore {}
 79
 80/// Lightweight metadata for any thread (native or ACP), enough to populate
 81/// the sidebar list and route to the correct load path when clicked.
 82#[derive(Debug, Clone)]
 83pub struct ThreadMetadata {
 84    pub session_id: acp::SessionId,
 85    /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
 86    pub agent_id: Option<AgentId>,
 87    pub title: SharedString,
 88    pub updated_at: DateTime<Utc>,
 89    pub created_at: Option<DateTime<Utc>>,
 90    pub folder_paths: PathList,
 91}
 92
 93impl ThreadMetadata {
 94    pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self {
 95        let session_id = session.session_id.clone();
 96        let title = session.title.clone().unwrap_or_default();
 97        let updated_at = session.updated_at.unwrap_or_else(|| Utc::now());
 98        let created_at = session.created_at.unwrap_or(updated_at);
 99        let folder_paths = session.work_dirs.clone().unwrap_or_default();
100        let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
101            None
102        } else {
103            Some(agent_id)
104        };
105        Self {
106            session_id,
107            agent_id,
108            title,
109            updated_at,
110            created_at: Some(created_at),
111            folder_paths,
112        }
113    }
114
115    pub fn from_thread(thread: &Entity<acp_thread::AcpThread>, cx: &App) -> Self {
116        let thread_ref = thread.read(cx);
117        let session_id = thread_ref.session_id().clone();
118        let title = thread_ref.title();
119        let updated_at = Utc::now();
120
121        let agent_id = thread_ref.connection().agent_id();
122
123        let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
124            None
125        } else {
126            Some(agent_id)
127        };
128
129        let folder_paths = {
130            let project = thread_ref.project().read(cx);
131            let paths: Vec<Arc<Path>> = project
132                .visible_worktrees(cx)
133                .map(|worktree| worktree.read(cx).abs_path())
134                .collect();
135            PathList::new(&paths)
136        };
137
138        Self {
139            session_id,
140            agent_id,
141            title,
142            created_at: Some(updated_at), // handled by db `ON CONFLICT`
143            updated_at,
144            folder_paths,
145        }
146    }
147}
148
149/// The store holds all metadata needed to show threads in the sidebar.
150/// Effectively, all threads stored in here are "non-archived".
151///
152/// Automatically listens to AcpThread events and updates metadata if it has changed.
153pub struct SidebarThreadMetadataStore {
154    db: ThreadMetadataDb,
155    threads: Vec<ThreadMetadata>,
156    threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
157    reload_task: Option<Shared<Task<()>>>,
158    session_subscriptions: HashMap<acp::SessionId, Subscription>,
159}
160
161impl SidebarThreadMetadataStore {
162    #[cfg(not(any(test, feature = "test-support")))]
163    pub fn init_global(cx: &mut App) {
164        if cx.has_global::<Self>() {
165            return;
166        }
167
168        let db = ThreadMetadataDb::global(cx);
169        let thread_store = cx.new(|cx| Self::new(db, cx));
170        cx.set_global(GlobalThreadMetadataStore(thread_store));
171    }
172
173    #[cfg(any(test, feature = "test-support"))]
174    pub fn init_global(cx: &mut App) {
175        let thread = std::thread::current();
176        let test_name = thread.name().unwrap_or("unknown_test");
177        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
178        let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
179        let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
180        cx.set_global(GlobalThreadMetadataStore(thread_store));
181    }
182
183    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
184        cx.try_global::<GlobalThreadMetadataStore>()
185            .map(|store| store.0.clone())
186    }
187
188    pub fn global(cx: &App) -> Entity<Self> {
189        cx.global::<GlobalThreadMetadataStore>().0.clone()
190    }
191
192    pub fn is_empty(&self) -> bool {
193        self.threads.is_empty()
194    }
195
196    pub fn entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
197        self.threads.iter().cloned()
198    }
199
200    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
201        self.threads.iter().map(|thread| thread.session_id.clone())
202    }
203
204    pub fn entries_for_path(
205        &self,
206        path_list: &PathList,
207    ) -> impl Iterator<Item = ThreadMetadata> + '_ {
208        self.threads_by_paths
209            .get(path_list)
210            .into_iter()
211            .flatten()
212            .cloned()
213    }
214
215    fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
216        let db = self.db.clone();
217        self.reload_task.take();
218
219        let list_task = cx
220            .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
221
222        let reload_task = cx
223            .spawn(async move |this, cx| {
224                let Some(rows) = list_task.await.log_err() else {
225                    return;
226                };
227
228                this.update(cx, |this, cx| {
229                    this.threads.clear();
230                    this.threads_by_paths.clear();
231
232                    for row in rows {
233                        this.threads_by_paths
234                            .entry(row.folder_paths.clone())
235                            .or_default()
236                            .push(row.clone());
237                        this.threads.push(row);
238                    }
239
240                    cx.notify();
241                })
242                .ok();
243            })
244            .shared();
245        self.reload_task = Some(reload_task.clone());
246        reload_task
247    }
248
249    pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) -> Task<Result<()>> {
250        if !cx.has_flag::<AgentV2FeatureFlag>() {
251            return Task::ready(Ok(()));
252        }
253
254        let db = self.db.clone();
255        cx.spawn(async move |this, cx| {
256            db.save(metadata).await?;
257            let reload_task = this.update(cx, |this, cx| this.reload(cx))?;
258            reload_task.await;
259            Ok(())
260        })
261    }
262
263    pub fn delete(
264        &mut self,
265        session_id: acp::SessionId,
266        cx: &mut Context<Self>,
267    ) -> Task<Result<()>> {
268        if !cx.has_flag::<AgentV2FeatureFlag>() {
269            return Task::ready(Ok(()));
270        }
271
272        let db = self.db.clone();
273        cx.spawn(async move |this, cx| {
274            db.delete(session_id).await?;
275            let reload_task = this.update(cx, |this, cx| this.reload(cx))?;
276            reload_task.await;
277            Ok(())
278        })
279    }
280
281    fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
282        let weak_store = cx.weak_entity();
283
284        cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
285            // Don't track subagent threads in the sidebar.
286            if thread.parent_session_id().is_some() {
287                return;
288            }
289
290            let thread_entity = cx.entity();
291
292            cx.on_release({
293                let weak_store = weak_store.clone();
294                move |thread, cx| {
295                    weak_store
296                        .update(cx, |store, _cx| {
297                            store.session_subscriptions.remove(thread.session_id());
298                        })
299                        .ok();
300                }
301            })
302            .detach();
303
304            weak_store
305                .update(cx, |this, cx| {
306                    let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update);
307                    this.session_subscriptions
308                        .insert(thread.session_id().clone(), subscription);
309                })
310                .ok();
311        })
312        .detach();
313
314        let mut this = Self {
315            db,
316            threads: Vec::new(),
317            threads_by_paths: HashMap::default(),
318            reload_task: None,
319            session_subscriptions: HashMap::default(),
320        };
321        let _ = this.reload(cx);
322        this
323    }
324
325    fn handle_thread_update(
326        &mut self,
327        thread: Entity<acp_thread::AcpThread>,
328        event: &acp_thread::AcpThreadEvent,
329        cx: &mut Context<Self>,
330    ) {
331        // Don't track subagent threads in the sidebar.
332        if thread.read(cx).parent_session_id().is_some() {
333            return;
334        }
335
336        match event {
337            acp_thread::AcpThreadEvent::NewEntry
338            | acp_thread::AcpThreadEvent::TitleUpdated
339            | acp_thread::AcpThreadEvent::EntryUpdated(_)
340            | acp_thread::AcpThreadEvent::EntriesRemoved(_)
341            | acp_thread::AcpThreadEvent::ToolAuthorizationRequested(_)
342            | acp_thread::AcpThreadEvent::ToolAuthorizationReceived(_)
343            | acp_thread::AcpThreadEvent::Retry(_)
344            | acp_thread::AcpThreadEvent::Stopped(_)
345            | acp_thread::AcpThreadEvent::Error
346            | acp_thread::AcpThreadEvent::LoadError(_)
347            | acp_thread::AcpThreadEvent::Refusal => {
348                let metadata = ThreadMetadata::from_thread(&thread, cx);
349                self.save(metadata, cx).detach_and_log_err(cx);
350            }
351            _ => {}
352        }
353    }
354}
355
356impl Global for SidebarThreadMetadataStore {}
357
358struct ThreadMetadataDb(ThreadSafeConnection);
359
360impl Domain for ThreadMetadataDb {
361    const NAME: &str = stringify!(ThreadMetadataDb);
362
363    const MIGRATIONS: &[&str] = &[sql!(
364        CREATE TABLE IF NOT EXISTS sidebar_threads(
365            session_id TEXT PRIMARY KEY,
366            agent_id TEXT,
367            title TEXT NOT NULL,
368            updated_at TEXT NOT NULL,
369            created_at TEXT,
370            folder_paths TEXT,
371            folder_paths_order TEXT
372        ) STRICT;
373    )];
374}
375
376db::static_connection!(ThreadMetadataDb, []);
377
378impl ThreadMetadataDb {
379    pub fn is_empty(&self) -> anyhow::Result<bool> {
380        self.select::<i64>("SELECT COUNT(*) FROM sidebar_threads")?()
381            .map(|counts| counts.into_iter().next().unwrap_or_default() == 0)
382    }
383
384    /// List all sidebar thread metadata, ordered by updated_at descending.
385    pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
386        self.select::<ThreadMetadata>(
387            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \
388             FROM sidebar_threads \
389             ORDER BY updated_at DESC"
390        )?()
391    }
392
393    /// Upsert metadata for a thread.
394    pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
395        let id = row.session_id.0.clone();
396        let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string());
397        let title = row.title.to_string();
398        let updated_at = row.updated_at.to_rfc3339();
399        let created_at = row.created_at.map(|dt| dt.to_rfc3339());
400        let serialized = row.folder_paths.serialize();
401        let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
402            (None, None)
403        } else {
404            (Some(serialized.paths), Some(serialized.order))
405        };
406
407        self.write(move |conn| {
408            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \
409                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
410                       ON CONFLICT(session_id) DO UPDATE SET \
411                           agent_id = excluded.agent_id, \
412                           title = excluded.title, \
413                           updated_at = excluded.updated_at, \
414                           folder_paths = excluded.folder_paths, \
415                           folder_paths_order = excluded.folder_paths_order";
416            let mut stmt = Statement::prepare(conn, sql)?;
417            let mut i = stmt.bind(&id, 1)?;
418            i = stmt.bind(&agent_id, i)?;
419            i = stmt.bind(&title, i)?;
420            i = stmt.bind(&updated_at, i)?;
421            i = stmt.bind(&created_at, i)?;
422            i = stmt.bind(&folder_paths, i)?;
423            stmt.bind(&folder_paths_order, i)?;
424            stmt.exec()
425        })
426        .await
427    }
428
429    /// Delete metadata for a single thread.
430    pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
431        let id = session_id.0.clone();
432        self.write(move |conn| {
433            let mut stmt =
434                Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
435            stmt.bind(&id, 1)?;
436            stmt.exec()
437        })
438        .await
439    }
440}
441
442impl Column for ThreadMetadata {
443    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
444        let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
445        let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
446        let (title, next): (String, i32) = Column::column(statement, next)?;
447        let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
448        let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
449        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
450        let (folder_paths_order_str, next): (Option<String>, i32) =
451            Column::column(statement, next)?;
452
453        let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
454        let created_at = created_at_str
455            .as_deref()
456            .map(DateTime::parse_from_rfc3339)
457            .transpose()?
458            .map(|dt| dt.with_timezone(&Utc));
459
460        let folder_paths = folder_paths_str
461            .map(|paths| {
462                PathList::deserialize(&util::path_list::SerializedPathList {
463                    paths,
464                    order: folder_paths_order_str.unwrap_or_default(),
465                })
466            })
467            .unwrap_or_default();
468
469        Ok((
470            ThreadMetadata {
471                session_id: acp::SessionId::new(id),
472                agent_id: agent_id.map(|id| AgentId::new(id)),
473                title: title.into(),
474                updated_at,
475                created_at,
476                folder_paths,
477            },
478            next,
479        ))
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use acp_thread::{AgentConnection, StubAgentConnection};
487    use action_log::ActionLog;
488    use agent::DbThread;
489    use agent_client_protocol as acp;
490    use feature_flags::FeatureFlagAppExt;
491    use gpui::TestAppContext;
492    use project::FakeFs;
493    use project::Project;
494    use std::path::Path;
495    use std::rc::Rc;
496
497    fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
498        DbThread {
499            title: title.to_string().into(),
500            messages: Vec::new(),
501            updated_at,
502            detailed_summary: None,
503            initial_project_snapshot: None,
504            cumulative_token_usage: Default::default(),
505            request_token_usage: Default::default(),
506            model: None,
507            profile: None,
508            imported: false,
509            subagent_context: None,
510            speed: None,
511            thinking_enabled: false,
512            thinking_effort: None,
513            draft_prompt: None,
514            ui_scroll_position: None,
515        }
516    }
517
518    fn make_metadata(
519        session_id: &str,
520        title: &str,
521        updated_at: DateTime<Utc>,
522        folder_paths: PathList,
523    ) -> ThreadMetadata {
524        ThreadMetadata {
525            session_id: acp::SessionId::new(session_id),
526            agent_id: None,
527            title: title.to_string().into(),
528            updated_at,
529            created_at: Some(updated_at),
530            folder_paths,
531        }
532    }
533
534    #[gpui::test]
535    async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
536        let first_paths = PathList::new(&[Path::new("/project-a")]);
537        let second_paths = PathList::new(&[Path::new("/project-b")]);
538        let now = Utc::now();
539        let older = now - chrono::Duration::seconds(1);
540
541        let thread = std::thread::current();
542        let test_name = thread.name().unwrap_or("unknown_test");
543        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
544        let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
545            &db_name,
546        )));
547
548        db.save(make_metadata(
549            "session-1",
550            "First Thread",
551            now,
552            first_paths.clone(),
553        ))
554        .await
555        .unwrap();
556        db.save(make_metadata(
557            "session-2",
558            "Second Thread",
559            older,
560            second_paths.clone(),
561        ))
562        .await
563        .unwrap();
564
565        cx.update(|cx| {
566            let settings_store = settings::SettingsStore::test(cx);
567            cx.set_global(settings_store);
568            cx.update_flags(true, vec!["agent-v2".to_string()]);
569            SidebarThreadMetadataStore::init_global(cx);
570        });
571
572        cx.run_until_parked();
573
574        cx.update(|cx| {
575            let store = SidebarThreadMetadataStore::global(cx);
576            let store = store.read(cx);
577
578            let entry_ids = store
579                .entry_ids()
580                .map(|session_id| session_id.0.to_string())
581                .collect::<Vec<_>>();
582            assert_eq!(entry_ids, vec!["session-1", "session-2"]);
583
584            let first_path_entries = store
585                .entries_for_path(&first_paths)
586                .map(|entry| entry.session_id.0.to_string())
587                .collect::<Vec<_>>();
588            assert_eq!(first_path_entries, vec!["session-1"]);
589
590            let second_path_entries = store
591                .entries_for_path(&second_paths)
592                .map(|entry| entry.session_id.0.to_string())
593                .collect::<Vec<_>>();
594            assert_eq!(second_path_entries, vec!["session-2"]);
595        });
596    }
597
598    #[gpui::test]
599    async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
600        cx.update(|cx| {
601            let settings_store = settings::SettingsStore::test(cx);
602            cx.set_global(settings_store);
603            cx.update_flags(true, vec!["agent-v2".to_string()]);
604            SidebarThreadMetadataStore::init_global(cx);
605        });
606
607        let first_paths = PathList::new(&[Path::new("/project-a")]);
608        let second_paths = PathList::new(&[Path::new("/project-b")]);
609        let initial_time = Utc::now();
610        let updated_time = initial_time + chrono::Duration::seconds(1);
611
612        let initial_metadata = make_metadata(
613            "session-1",
614            "First Thread",
615            initial_time,
616            first_paths.clone(),
617        );
618
619        let second_metadata = make_metadata(
620            "session-2",
621            "Second Thread",
622            initial_time,
623            second_paths.clone(),
624        );
625
626        cx.update(|cx| {
627            let store = SidebarThreadMetadataStore::global(cx);
628            store.update(cx, |store, cx| {
629                store.save(initial_metadata, cx).detach();
630                store.save(second_metadata, cx).detach();
631            });
632        });
633
634        cx.run_until_parked();
635
636        cx.update(|cx| {
637            let store = SidebarThreadMetadataStore::global(cx);
638            let store = store.read(cx);
639
640            let first_path_entries = store
641                .entries_for_path(&first_paths)
642                .map(|entry| entry.session_id.0.to_string())
643                .collect::<Vec<_>>();
644            assert_eq!(first_path_entries, vec!["session-1"]);
645
646            let second_path_entries = store
647                .entries_for_path(&second_paths)
648                .map(|entry| entry.session_id.0.to_string())
649                .collect::<Vec<_>>();
650            assert_eq!(second_path_entries, vec!["session-2"]);
651        });
652
653        let moved_metadata = make_metadata(
654            "session-1",
655            "First Thread",
656            updated_time,
657            second_paths.clone(),
658        );
659
660        cx.update(|cx| {
661            let store = SidebarThreadMetadataStore::global(cx);
662            store.update(cx, |store, cx| {
663                store.save(moved_metadata, cx).detach();
664            });
665        });
666
667        cx.run_until_parked();
668
669        cx.update(|cx| {
670            let store = SidebarThreadMetadataStore::global(cx);
671            let store = store.read(cx);
672
673            let entry_ids = store
674                .entry_ids()
675                .map(|session_id| session_id.0.to_string())
676                .collect::<Vec<_>>();
677            assert_eq!(entry_ids, vec!["session-1", "session-2"]);
678
679            let first_path_entries = store
680                .entries_for_path(&first_paths)
681                .map(|entry| entry.session_id.0.to_string())
682                .collect::<Vec<_>>();
683            assert!(first_path_entries.is_empty());
684
685            let second_path_entries = store
686                .entries_for_path(&second_paths)
687                .map(|entry| entry.session_id.0.to_string())
688                .collect::<Vec<_>>();
689            assert_eq!(second_path_entries, vec!["session-1", "session-2"]);
690        });
691
692        cx.update(|cx| {
693            let store = SidebarThreadMetadataStore::global(cx);
694            store.update(cx, |store, cx| {
695                store.delete(acp::SessionId::new("session-2"), cx).detach();
696            });
697        });
698
699        cx.run_until_parked();
700
701        cx.update(|cx| {
702            let store = SidebarThreadMetadataStore::global(cx);
703            let store = store.read(cx);
704
705            let entry_ids = store
706                .entry_ids()
707                .map(|session_id| session_id.0.to_string())
708                .collect::<Vec<_>>();
709            assert_eq!(entry_ids, vec!["session-1"]);
710
711            let second_path_entries = store
712                .entries_for_path(&second_paths)
713                .map(|entry| entry.session_id.0.to_string())
714                .collect::<Vec<_>>();
715            assert_eq!(second_path_entries, vec!["session-1"]);
716        });
717    }
718
719    #[gpui::test]
720    async fn test_migrate_thread_metadata(cx: &mut TestAppContext) {
721        cx.update(|cx| {
722            ThreadStore::init_global(cx);
723            SidebarThreadMetadataStore::init_global(cx);
724        });
725
726        // Verify the cache is empty before migration
727        let list = cx.update(|cx| {
728            let store = SidebarThreadMetadataStore::global(cx);
729            store.read(cx).entries().collect::<Vec<_>>()
730        });
731        assert_eq!(list.len(), 0);
732
733        let now = Utc::now();
734
735        // Populate the native ThreadStore via save_thread
736        let save1 = cx.update(|cx| {
737            let thread_store = ThreadStore::global(cx);
738            thread_store.update(cx, |store, cx| {
739                store.save_thread(
740                    acp::SessionId::new("session-1"),
741                    make_db_thread("Thread 1", now),
742                    PathList::default(),
743                    cx,
744                )
745            })
746        });
747        save1.await.unwrap();
748        cx.run_until_parked();
749
750        let save2 = cx.update(|cx| {
751            let thread_store = ThreadStore::global(cx);
752            thread_store.update(cx, |store, cx| {
753                store.save_thread(
754                    acp::SessionId::new("session-2"),
755                    make_db_thread("Thread 2", now),
756                    PathList::default(),
757                    cx,
758                )
759            })
760        });
761        save2.await.unwrap();
762        cx.run_until_parked();
763
764        // Run migration
765        cx.update(|cx| {
766            migrate_thread_metadata(cx);
767        });
768
769        cx.run_until_parked();
770
771        // Verify the metadata was migrated
772        let list = cx.update(|cx| {
773            let store = SidebarThreadMetadataStore::global(cx);
774            store.read(cx).entries().collect::<Vec<_>>()
775        });
776        assert_eq!(list.len(), 2);
777
778        let metadata1 = list
779            .iter()
780            .find(|m| m.session_id.0.as_ref() == "session-1")
781            .expect("session-1 should be in migrated metadata");
782        assert_eq!(metadata1.title.as_ref(), "Thread 1");
783        assert!(metadata1.agent_id.is_none());
784
785        let metadata2 = list
786            .iter()
787            .find(|m| m.session_id.0.as_ref() == "session-2")
788            .expect("session-2 should be in migrated metadata");
789        assert_eq!(metadata2.title.as_ref(), "Thread 2");
790        assert!(metadata2.agent_id.is_none());
791    }
792
793    #[gpui::test]
794    async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) {
795        cx.update(|cx| {
796            ThreadStore::init_global(cx);
797            SidebarThreadMetadataStore::init_global(cx);
798        });
799
800        // Pre-populate the metadata store with existing data
801        let existing_metadata = ThreadMetadata {
802            session_id: acp::SessionId::new("existing-session"),
803            agent_id: None,
804            title: "Existing Thread".into(),
805            updated_at: Utc::now(),
806            created_at: Some(Utc::now()),
807            folder_paths: PathList::default(),
808        };
809
810        cx.update(|cx| {
811            let store = SidebarThreadMetadataStore::global(cx);
812            store.update(cx, |store, cx| {
813                store.save(existing_metadata, cx).detach();
814            });
815        });
816
817        cx.run_until_parked();
818
819        // Add an entry to native thread store that should NOT be migrated
820        let save_task = cx.update(|cx| {
821            let thread_store = ThreadStore::global(cx);
822            thread_store.update(cx, |store, cx| {
823                store.save_thread(
824                    acp::SessionId::new("native-session"),
825                    make_db_thread("Native Thread", Utc::now()),
826                    PathList::default(),
827                    cx,
828                )
829            })
830        });
831        save_task.await.unwrap();
832        cx.run_until_parked();
833
834        // Run migration - should skip because metadata store is not empty
835        cx.update(|cx| {
836            migrate_thread_metadata(cx);
837        });
838
839        cx.run_until_parked();
840
841        // Verify only the existing metadata is present (migration was skipped)
842        let list = cx.update(|cx| {
843            let store = SidebarThreadMetadataStore::global(cx);
844            store.read(cx).entries().collect::<Vec<_>>()
845        });
846        assert_eq!(list.len(), 1);
847        assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
848    }
849
850    #[gpui::test]
851    async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
852        cx.update(|cx| {
853            let settings_store = settings::SettingsStore::test(cx);
854            cx.set_global(settings_store);
855            cx.update_flags(true, vec!["agent-v2".to_string()]);
856            ThreadStore::init_global(cx);
857            SidebarThreadMetadataStore::init_global(cx);
858        });
859
860        let fs = FakeFs::new(cx.executor());
861        let project = Project::test(fs, None::<&Path>, cx).await;
862        let connection = Rc::new(StubAgentConnection::new());
863
864        // Create a regular (non-subagent) AcpThread.
865        let regular_thread = cx
866            .update(|cx| {
867                connection
868                    .clone()
869                    .new_session(project.clone(), PathList::default(), cx)
870            })
871            .await
872            .unwrap();
873
874        let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
875
876        // Set a title on the regular thread to trigger a save via handle_thread_update.
877        cx.update(|cx| {
878            regular_thread.update(cx, |thread, cx| {
879                thread.set_title("Regular Thread".into(), cx).detach();
880            });
881        });
882        cx.run_until_parked();
883
884        // Create a subagent AcpThread
885        let subagent_session_id = acp::SessionId::new("subagent-session");
886        let subagent_thread = cx.update(|cx| {
887            let action_log = cx.new(|_| ActionLog::new(project.clone()));
888            cx.new(|cx| {
889                acp_thread::AcpThread::new(
890                    Some(regular_session_id.clone()),
891                    "Subagent Thread",
892                    None,
893                    connection.clone(),
894                    project.clone(),
895                    action_log,
896                    subagent_session_id.clone(),
897                    watch::Receiver::constant(acp::PromptCapabilities::new()),
898                    cx,
899                )
900            })
901        });
902
903        // Set a title on the subagent thread to trigger handle_thread_update.
904        cx.update(|cx| {
905            subagent_thread.update(cx, |thread, cx| {
906                thread
907                    .set_title("Subagent Thread Title".into(), cx)
908                    .detach();
909            });
910        });
911        cx.run_until_parked();
912
913        // List all metadata from the store cache.
914        let list = cx.update(|cx| {
915            let store = SidebarThreadMetadataStore::global(cx);
916            store.read(cx).entries().collect::<Vec<_>>()
917        });
918
919        // The subagent thread should NOT appear in the sidebar metadata.
920        // Only the regular thread should be listed.
921        assert_eq!(
922            list.len(),
923            1,
924            "Expected only the regular thread in sidebar metadata, \
925             but found {} entries (subagent threads are leaking into the sidebar)",
926            list.len(),
927        );
928        assert_eq!(list[0].session_id, regular_session_id);
929        assert_eq!(list[0].title.as_ref(), "Regular Thread");
930    }
931}