Add `agent_worktree_directory` to `GitSettings` for configuring where agent worktrees are stored (default: Zed data dir). Remove `Copy` derive from `GitSettings`/`GitContentSettings` (incompatible with String field) and fix downstream `.as_ref().unwrap()` call sites. Define `AgentGitWorktreeInfo` (branch, worktree_path, base_ref) and add it to `DbThread` + `DbThreadMetadata` for persistence and session list display. Closes AI-33 Release Notes: - N/A
847 lines
29 KiB
Rust
847 lines
29 KiB
Rust
use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent};
|
|
use acp_thread::UserMessageId;
|
|
use agent_client_protocol as acp;
|
|
use agent_settings::AgentProfileId;
|
|
use anyhow::{Result, anyhow};
|
|
use chrono::{DateTime, Utc};
|
|
use collections::{HashMap, IndexMap};
|
|
use futures::{FutureExt, future::Shared};
|
|
use gpui::{BackgroundExecutor, Global, Task};
|
|
use indoc::indoc;
|
|
use parking_lot::Mutex;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlez::{
|
|
bindable::{Bind, Column},
|
|
connection::Connection,
|
|
statement::Statement,
|
|
};
|
|
use std::sync::Arc;
|
|
use ui::{App, SharedString};
|
|
use zed_env_vars::ZED_STATELESS;
|
|
|
|
pub type DbMessage = crate::Message;
|
|
pub type DbSummary = crate::legacy_thread::DetailedSummaryState;
|
|
pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel;
|
|
|
|
/// Metadata about the git worktree associated with an agent thread.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentGitWorktreeInfo {
|
|
/// The branch name in the git worktree.
|
|
pub branch: String,
|
|
/// Absolute path to the git worktree on disk.
|
|
pub worktree_path: std::path::PathBuf,
|
|
/// The base branch/commit the worktree was created from.
|
|
pub base_ref: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DbThreadMetadata {
|
|
pub id: acp::SessionId,
|
|
pub parent_session_id: Option<acp::SessionId>,
|
|
#[serde(alias = "summary")]
|
|
pub title: SharedString,
|
|
pub updated_at: DateTime<Utc>,
|
|
/// Denormalized from `DbThread::git_worktree_info.branch` for efficient
|
|
/// listing without decompressing thread data. The blob is the source of
|
|
/// truth; this column is populated on save for query convenience.
|
|
pub worktree_branch: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct DbThread {
|
|
pub title: SharedString,
|
|
pub messages: Vec<DbMessage>,
|
|
pub updated_at: DateTime<Utc>,
|
|
#[serde(default)]
|
|
pub detailed_summary: Option<SharedString>,
|
|
#[serde(default)]
|
|
pub initial_project_snapshot: Option<Arc<crate::ProjectSnapshot>>,
|
|
#[serde(default)]
|
|
pub cumulative_token_usage: language_model::TokenUsage,
|
|
#[serde(default)]
|
|
pub request_token_usage: HashMap<acp_thread::UserMessageId, language_model::TokenUsage>,
|
|
#[serde(default)]
|
|
pub model: Option<DbLanguageModel>,
|
|
#[serde(default)]
|
|
pub profile: Option<AgentProfileId>,
|
|
#[serde(default)]
|
|
pub imported: bool,
|
|
#[serde(default)]
|
|
pub subagent_context: Option<crate::SubagentContext>,
|
|
#[serde(default)]
|
|
pub git_worktree_info: Option<AgentGitWorktreeInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SharedThread {
|
|
pub title: SharedString,
|
|
pub messages: Vec<DbMessage>,
|
|
pub updated_at: DateTime<Utc>,
|
|
#[serde(default)]
|
|
pub model: Option<DbLanguageModel>,
|
|
pub version: String,
|
|
}
|
|
|
|
impl SharedThread {
|
|
pub const VERSION: &'static str = "1.0.0";
|
|
|
|
pub fn from_db_thread(thread: &DbThread) -> Self {
|
|
Self {
|
|
title: thread.title.clone(),
|
|
messages: thread.messages.clone(),
|
|
updated_at: thread.updated_at,
|
|
model: thread.model.clone(),
|
|
version: Self::VERSION.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn to_db_thread(self) -> DbThread {
|
|
DbThread {
|
|
title: format!("🔗 {}", self.title).into(),
|
|
messages: self.messages,
|
|
updated_at: self.updated_at,
|
|
detailed_summary: None,
|
|
initial_project_snapshot: None,
|
|
cumulative_token_usage: Default::default(),
|
|
request_token_usage: Default::default(),
|
|
model: self.model,
|
|
profile: None,
|
|
imported: true,
|
|
subagent_context: None,
|
|
git_worktree_info: None,
|
|
}
|
|
}
|
|
|
|
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
|
const COMPRESSION_LEVEL: i32 = 3;
|
|
let json = serde_json::to_vec(self)?;
|
|
let compressed = zstd::encode_all(json.as_slice(), COMPRESSION_LEVEL)?;
|
|
Ok(compressed)
|
|
}
|
|
|
|
pub fn from_bytes(data: &[u8]) -> Result<Self> {
|
|
let decompressed = zstd::decode_all(data)?;
|
|
Ok(serde_json::from_slice(&decompressed)?)
|
|
}
|
|
}
|
|
|
|
impl DbThread {
|
|
pub const VERSION: &'static str = "0.3.0";
|
|
|
|
pub fn from_json(json: &[u8]) -> Result<Self> {
|
|
let saved_thread_json = serde_json::from_slice::<serde_json::Value>(json)?;
|
|
match saved_thread_json.get("version") {
|
|
Some(serde_json::Value::String(version)) => match version.as_str() {
|
|
Self::VERSION => Ok(serde_json::from_value(saved_thread_json)?),
|
|
_ => Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json(
|
|
json,
|
|
)?),
|
|
},
|
|
_ => {
|
|
Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json(json)?)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn upgrade_from_agent_1(thread: crate::legacy_thread::SerializedThread) -> Result<Self> {
|
|
let mut messages = Vec::new();
|
|
let mut request_token_usage = HashMap::default();
|
|
|
|
let mut last_user_message_id = None;
|
|
for (ix, msg) in thread.messages.into_iter().enumerate() {
|
|
let message = match msg.role {
|
|
language_model::Role::User => {
|
|
let mut content = Vec::new();
|
|
|
|
// Convert segments to content
|
|
for segment in msg.segments {
|
|
match segment {
|
|
crate::legacy_thread::SerializedMessageSegment::Text { text } => {
|
|
content.push(UserMessageContent::Text(text));
|
|
}
|
|
crate::legacy_thread::SerializedMessageSegment::Thinking {
|
|
text,
|
|
..
|
|
} => {
|
|
// User messages don't have thinking segments, but handle gracefully
|
|
content.push(UserMessageContent::Text(text));
|
|
}
|
|
crate::legacy_thread::SerializedMessageSegment::RedactedThinking {
|
|
..
|
|
} => {
|
|
// User messages don't have redacted thinking, skip.
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no content was added, add context as text if available
|
|
if content.is_empty() && !msg.context.is_empty() {
|
|
content.push(UserMessageContent::Text(msg.context));
|
|
}
|
|
|
|
let id = UserMessageId::new();
|
|
last_user_message_id = Some(id.clone());
|
|
|
|
crate::Message::User(UserMessage {
|
|
// MessageId from old format can't be meaningfully converted, so generate a new one
|
|
id,
|
|
content,
|
|
})
|
|
}
|
|
language_model::Role::Assistant => {
|
|
let mut content = Vec::new();
|
|
|
|
// Convert segments to content
|
|
for segment in msg.segments {
|
|
match segment {
|
|
crate::legacy_thread::SerializedMessageSegment::Text { text } => {
|
|
content.push(AgentMessageContent::Text(text));
|
|
}
|
|
crate::legacy_thread::SerializedMessageSegment::Thinking {
|
|
text,
|
|
signature,
|
|
} => {
|
|
content.push(AgentMessageContent::Thinking { text, signature });
|
|
}
|
|
crate::legacy_thread::SerializedMessageSegment::RedactedThinking {
|
|
data,
|
|
} => {
|
|
content.push(AgentMessageContent::RedactedThinking(data));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert tool uses
|
|
let mut tool_names_by_id = HashMap::default();
|
|
for tool_use in msg.tool_uses {
|
|
tool_names_by_id.insert(tool_use.id.clone(), tool_use.name.clone());
|
|
content.push(AgentMessageContent::ToolUse(
|
|
language_model::LanguageModelToolUse {
|
|
id: tool_use.id,
|
|
name: tool_use.name.into(),
|
|
raw_input: serde_json::to_string(&tool_use.input)
|
|
.unwrap_or_default(),
|
|
input: tool_use.input,
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
},
|
|
));
|
|
}
|
|
|
|
// Convert tool results
|
|
let mut tool_results = IndexMap::default();
|
|
for tool_result in msg.tool_results {
|
|
let name = tool_names_by_id
|
|
.remove(&tool_result.tool_use_id)
|
|
.unwrap_or_else(|| SharedString::from("unknown"));
|
|
tool_results.insert(
|
|
tool_result.tool_use_id.clone(),
|
|
language_model::LanguageModelToolResult {
|
|
tool_use_id: tool_result.tool_use_id,
|
|
tool_name: name.into(),
|
|
is_error: tool_result.is_error,
|
|
content: tool_result.content,
|
|
output: tool_result.output,
|
|
},
|
|
);
|
|
}
|
|
|
|
if let Some(last_user_message_id) = &last_user_message_id
|
|
&& let Some(token_usage) = thread.request_token_usage.get(ix).copied()
|
|
{
|
|
request_token_usage.insert(last_user_message_id.clone(), token_usage);
|
|
}
|
|
|
|
crate::Message::Agent(AgentMessage {
|
|
content,
|
|
tool_results,
|
|
reasoning_details: None,
|
|
})
|
|
}
|
|
language_model::Role::System => {
|
|
// Skip system messages as they're not supported in the new format
|
|
continue;
|
|
}
|
|
};
|
|
|
|
messages.push(message);
|
|
}
|
|
|
|
Ok(Self {
|
|
title: thread.summary,
|
|
messages,
|
|
updated_at: thread.updated_at,
|
|
detailed_summary: match thread.detailed_summary_state {
|
|
crate::legacy_thread::DetailedSummaryState::NotGenerated
|
|
| crate::legacy_thread::DetailedSummaryState::Generating => None,
|
|
crate::legacy_thread::DetailedSummaryState::Generated { text, .. } => Some(text),
|
|
},
|
|
initial_project_snapshot: thread.initial_project_snapshot,
|
|
cumulative_token_usage: thread.cumulative_token_usage,
|
|
request_token_usage,
|
|
model: thread.model,
|
|
profile: thread.profile,
|
|
imported: false,
|
|
subagent_context: None,
|
|
git_worktree_info: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum DataType {
|
|
#[serde(rename = "json")]
|
|
Json,
|
|
#[serde(rename = "zstd")]
|
|
Zstd,
|
|
}
|
|
|
|
impl Bind for DataType {
|
|
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
|
|
let value = match self {
|
|
DataType::Json => "json",
|
|
DataType::Zstd => "zstd",
|
|
};
|
|
value.bind(statement, start_index)
|
|
}
|
|
}
|
|
|
|
impl Column for DataType {
|
|
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
|
let (value, next_index) = String::column(statement, start_index)?;
|
|
let data_type = match value.as_str() {
|
|
"json" => DataType::Json,
|
|
"zstd" => DataType::Zstd,
|
|
_ => anyhow::bail!("Unknown data type: {}", value),
|
|
};
|
|
Ok((data_type, next_index))
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ThreadsDatabase {
|
|
executor: BackgroundExecutor,
|
|
connection: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
struct GlobalThreadsDatabase(Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>>);
|
|
|
|
impl Global for GlobalThreadsDatabase {}
|
|
|
|
impl ThreadsDatabase {
|
|
pub fn connect(cx: &mut App) -> Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>> {
|
|
if cx.has_global::<GlobalThreadsDatabase>() {
|
|
return cx.global::<GlobalThreadsDatabase>().0.clone();
|
|
}
|
|
let executor = cx.background_executor().clone();
|
|
let task = executor
|
|
.spawn({
|
|
let executor = executor.clone();
|
|
async move {
|
|
match ThreadsDatabase::new(executor) {
|
|
Ok(db) => Ok(Arc::new(db)),
|
|
Err(err) => Err(Arc::new(err)),
|
|
}
|
|
}
|
|
})
|
|
.shared();
|
|
|
|
cx.set_global(GlobalThreadsDatabase(task.clone()));
|
|
task
|
|
}
|
|
|
|
pub fn new(executor: BackgroundExecutor) -> Result<Self> {
|
|
let connection = if *ZED_STATELESS {
|
|
Connection::open_memory(Some("THREAD_FALLBACK_DB"))
|
|
} else if cfg!(any(feature = "test-support", test)) {
|
|
// rust stores the name of the test on the current thread.
|
|
// We use this to automatically create a database that will
|
|
// be shared within the test (for the test_retrieve_old_thread)
|
|
// but not with concurrent tests.
|
|
let thread = std::thread::current();
|
|
let test_name = thread.name();
|
|
Connection::open_memory(Some(&format!(
|
|
"THREAD_FALLBACK_{}",
|
|
test_name.unwrap_or_default()
|
|
)))
|
|
} else {
|
|
let threads_dir = paths::data_dir().join("threads");
|
|
std::fs::create_dir_all(&threads_dir)?;
|
|
let sqlite_path = threads_dir.join("threads.db");
|
|
Connection::open_file(&sqlite_path.to_string_lossy())
|
|
};
|
|
|
|
connection.exec(indoc! {"
|
|
CREATE TABLE IF NOT EXISTS threads (
|
|
id TEXT PRIMARY KEY,
|
|
summary TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
data_type TEXT NOT NULL,
|
|
data BLOB NOT NULL
|
|
)
|
|
"})?()
|
|
.map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
|
|
|
|
if let Ok(mut s) = connection.exec(indoc! {"
|
|
ALTER TABLE threads ADD COLUMN parent_id TEXT
|
|
"})
|
|
{
|
|
s().ok();
|
|
}
|
|
|
|
if let Ok(mut s) = connection.exec(indoc! {"
|
|
ALTER TABLE threads ADD COLUMN worktree_branch TEXT
|
|
"})
|
|
{
|
|
s().ok();
|
|
}
|
|
|
|
let db = Self {
|
|
executor,
|
|
connection: Arc::new(Mutex::new(connection)),
|
|
};
|
|
|
|
Ok(db)
|
|
}
|
|
|
|
fn save_thread_sync(
|
|
connection: &Arc<Mutex<Connection>>,
|
|
id: acp::SessionId,
|
|
thread: DbThread,
|
|
) -> Result<()> {
|
|
const COMPRESSION_LEVEL: i32 = 3;
|
|
|
|
#[derive(Serialize)]
|
|
struct SerializedThread {
|
|
#[serde(flatten)]
|
|
thread: DbThread,
|
|
version: &'static str,
|
|
}
|
|
|
|
let title = thread.title.to_string();
|
|
let updated_at = thread.updated_at.to_rfc3339();
|
|
let parent_id = thread
|
|
.subagent_context
|
|
.as_ref()
|
|
.map(|ctx| ctx.parent_thread_id.0.clone());
|
|
let worktree_branch = thread
|
|
.git_worktree_info
|
|
.as_ref()
|
|
.map(|info| info.branch.clone());
|
|
let json_data = serde_json::to_string(&SerializedThread {
|
|
thread,
|
|
version: DbThread::VERSION,
|
|
})?;
|
|
|
|
let connection = connection.lock();
|
|
|
|
let compressed = zstd::encode_all(json_data.as_bytes(), COMPRESSION_LEVEL)?;
|
|
let data_type = DataType::Zstd;
|
|
let data = compressed;
|
|
|
|
let mut insert = connection.exec_bound::<(Arc<str>, Option<Arc<str>>, Option<String>, String, String, DataType, Vec<u8>)>(indoc! {"
|
|
INSERT OR REPLACE INTO threads (id, parent_id, worktree_branch, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
"})?;
|
|
|
|
insert((
|
|
id.0,
|
|
parent_id,
|
|
worktree_branch,
|
|
title,
|
|
updated_at,
|
|
data_type,
|
|
data,
|
|
))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_threads(&self) -> Task<Result<Vec<DbThreadMetadata>>> {
|
|
let connection = self.connection.clone();
|
|
|
|
self.executor.spawn(async move {
|
|
let connection = connection.lock();
|
|
|
|
let mut select = connection
|
|
.select_bound::<(), (Arc<str>, Option<Arc<str>>, Option<String>, String, String)>(indoc! {"
|
|
SELECT id, parent_id, worktree_branch, summary, updated_at FROM threads ORDER BY updated_at DESC
|
|
"})?;
|
|
|
|
let rows = select(())?;
|
|
let mut threads = Vec::new();
|
|
|
|
for (id, parent_id, worktree_branch, summary, updated_at) in rows {
|
|
threads.push(DbThreadMetadata {
|
|
id: acp::SessionId::new(id),
|
|
parent_session_id: parent_id.map(acp::SessionId::new),
|
|
title: summary.into(),
|
|
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
|
|
worktree_branch,
|
|
});
|
|
}
|
|
|
|
Ok(threads)
|
|
})
|
|
}
|
|
|
|
pub fn load_thread(&self, id: acp::SessionId) -> Task<Result<Option<DbThread>>> {
|
|
let connection = self.connection.clone();
|
|
|
|
self.executor.spawn(async move {
|
|
let connection = connection.lock();
|
|
let mut select = connection.select_bound::<Arc<str>, (DataType, Vec<u8>)>(indoc! {"
|
|
SELECT data_type, data FROM threads WHERE id = ? LIMIT 1
|
|
"})?;
|
|
|
|
let rows = select(id.0)?;
|
|
if let Some((data_type, data)) = rows.into_iter().next() {
|
|
let json_data = match data_type {
|
|
DataType::Zstd => {
|
|
let decompressed = zstd::decode_all(&data[..])?;
|
|
String::from_utf8(decompressed)?
|
|
}
|
|
DataType::Json => String::from_utf8(data)?,
|
|
};
|
|
let thread = DbThread::from_json(json_data.as_bytes())?;
|
|
Ok(Some(thread))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn save_thread(&self, id: acp::SessionId, thread: DbThread) -> Task<Result<()>> {
|
|
let connection = self.connection.clone();
|
|
|
|
self.executor
|
|
.spawn(async move { Self::save_thread_sync(&connection, id, thread) })
|
|
}
|
|
|
|
pub fn delete_thread(&self, id: acp::SessionId) -> Task<Result<()>> {
|
|
let connection = self.connection.clone();
|
|
|
|
self.executor.spawn(async move {
|
|
let connection = connection.lock();
|
|
|
|
let mut delete = connection.exec_bound::<Arc<str>>(indoc! {"
|
|
DELETE FROM threads WHERE id = ?
|
|
"})?;
|
|
|
|
delete(id.0)?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
pub fn delete_threads(&self) -> Task<Result<()>> {
|
|
let connection = self.connection.clone();
|
|
|
|
self.executor.spawn(async move {
|
|
let connection = connection.lock();
|
|
|
|
let mut delete = connection.exec_bound::<()>(indoc! {"
|
|
DELETE FROM threads
|
|
"})?;
|
|
|
|
delete(())?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::{DateTime, TimeZone, Utc};
|
|
use collections::HashMap;
|
|
use gpui::TestAppContext;
|
|
use std::sync::Arc;
|
|
|
|
#[test]
|
|
fn test_shared_thread_roundtrip() {
|
|
let original = SharedThread {
|
|
title: "Test Thread".into(),
|
|
messages: vec![],
|
|
updated_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
|
model: None,
|
|
version: SharedThread::VERSION.to_string(),
|
|
};
|
|
|
|
let bytes = original.to_bytes().expect("Failed to serialize");
|
|
let restored = SharedThread::from_bytes(&bytes).expect("Failed to deserialize");
|
|
|
|
assert_eq!(restored.title, original.title);
|
|
assert_eq!(restored.version, original.version);
|
|
assert_eq!(restored.updated_at, original.updated_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_imported_flag_defaults_to_false() {
|
|
// Simulate deserializing a thread without the imported field (backwards compatibility).
|
|
let json = r#"{
|
|
"title": "Old Thread",
|
|
"messages": [],
|
|
"updated_at": "2024-01-01T00:00:00Z"
|
|
}"#;
|
|
|
|
let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize");
|
|
|
|
assert!(
|
|
!db_thread.imported,
|
|
"Legacy threads without imported field should default to false"
|
|
);
|
|
}
|
|
|
|
fn session_id(value: &str) -> acp::SessionId {
|
|
acp::SessionId::new(Arc::<str>::from(value))
|
|
}
|
|
|
|
fn make_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
|
|
DbThread {
|
|
title: title.to_string().into(),
|
|
messages: Vec::new(),
|
|
updated_at,
|
|
detailed_summary: None,
|
|
initial_project_snapshot: None,
|
|
cumulative_token_usage: Default::default(),
|
|
request_token_usage: HashMap::default(),
|
|
model: None,
|
|
profile: None,
|
|
imported: false,
|
|
subagent_context: None,
|
|
git_worktree_info: None,
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_threads_orders_by_updated_at(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
let older_id = session_id("thread-a");
|
|
let newer_id = session_id("thread-b");
|
|
|
|
let older_thread = make_thread(
|
|
"Thread A",
|
|
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
|
);
|
|
let newer_thread = make_thread(
|
|
"Thread B",
|
|
Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(),
|
|
);
|
|
|
|
database
|
|
.save_thread(older_id.clone(), older_thread)
|
|
.await
|
|
.unwrap();
|
|
database
|
|
.save_thread(newer_id.clone(), newer_thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
let entries = database.list_threads().await.unwrap();
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[0].id, newer_id);
|
|
assert_eq!(entries[1].id, older_id);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_thread_replaces_metadata(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
let thread_id = session_id("thread-a");
|
|
let original_thread = make_thread(
|
|
"Thread A",
|
|
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
|
);
|
|
let updated_thread = make_thread(
|
|
"Thread B",
|
|
Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(),
|
|
);
|
|
|
|
database
|
|
.save_thread(thread_id.clone(), original_thread)
|
|
.await
|
|
.unwrap();
|
|
database
|
|
.save_thread(thread_id.clone(), updated_thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
let entries = database.list_threads().await.unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].id, thread_id);
|
|
assert_eq!(entries[0].title.as_ref(), "Thread B");
|
|
assert_eq!(
|
|
entries[0].updated_at,
|
|
Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subagent_context_defaults_to_none() {
|
|
let json = r#"{
|
|
"title": "Old Thread",
|
|
"messages": [],
|
|
"updated_at": "2024-01-01T00:00:00Z"
|
|
}"#;
|
|
|
|
let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize");
|
|
|
|
assert!(
|
|
db_thread.subagent_context.is_none(),
|
|
"Legacy threads without subagent_context should default to None"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
let parent_id = session_id("parent-thread");
|
|
let child_id = session_id("child-thread");
|
|
|
|
let mut child_thread = make_thread(
|
|
"Subagent Thread",
|
|
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
|
);
|
|
child_thread.subagent_context = Some(crate::SubagentContext {
|
|
parent_thread_id: parent_id.clone(),
|
|
depth: 2,
|
|
});
|
|
|
|
database
|
|
.save_thread(child_id.clone(), child_thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = database
|
|
.load_thread(child_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("thread should exist");
|
|
|
|
let context = loaded
|
|
.subagent_context
|
|
.expect("subagent_context should be restored");
|
|
assert_eq!(context.parent_thread_id, parent_id);
|
|
assert_eq!(context.depth, 2);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_non_subagent_thread_has_no_subagent_context(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
let thread_id = session_id("regular-thread");
|
|
let thread = make_thread(
|
|
"Regular Thread",
|
|
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
|
);
|
|
|
|
database
|
|
.save_thread(thread_id.clone(), thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = database
|
|
.load_thread(thread_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("thread should exist");
|
|
|
|
assert!(
|
|
loaded.subagent_context.is_none(),
|
|
"Regular threads should have no subagent_context"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_worktree_info_roundtrip(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
let thread_id = session_id("worktree-thread");
|
|
let mut thread = make_thread(
|
|
"Worktree Thread",
|
|
Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
|
|
);
|
|
thread.git_worktree_info = Some(AgentGitWorktreeInfo {
|
|
branch: "zed/agent/a4Xiu".to_string(),
|
|
worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu"),
|
|
base_ref: "main".to_string(),
|
|
});
|
|
|
|
database
|
|
.save_thread(thread_id.clone(), thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = database
|
|
.load_thread(thread_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("thread should exist");
|
|
|
|
let info = loaded
|
|
.git_worktree_info
|
|
.expect("git_worktree_info should be restored");
|
|
assert_eq!(info.branch, "zed/agent/a4Xiu");
|
|
assert_eq!(
|
|
info.worktree_path,
|
|
std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu")
|
|
);
|
|
assert_eq!(info.base_ref, "main");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_session_list_includes_worktree_meta(cx: &mut TestAppContext) {
|
|
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
|
|
|
// Save a thread with worktree info
|
|
let worktree_id = session_id("wt-thread");
|
|
let mut worktree_thread = make_thread(
|
|
"With Worktree",
|
|
Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
|
|
);
|
|
worktree_thread.git_worktree_info = Some(AgentGitWorktreeInfo {
|
|
branch: "zed/agent/bR9kz".to_string(),
|
|
worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/bR9kz"),
|
|
base_ref: "develop".to_string(),
|
|
});
|
|
|
|
database
|
|
.save_thread(worktree_id.clone(), worktree_thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Save a thread without worktree info
|
|
let plain_id = session_id("plain-thread");
|
|
let plain_thread = make_thread(
|
|
"Without Worktree",
|
|
Utc.with_ymd_and_hms(2024, 6, 15, 11, 0, 0).unwrap(),
|
|
);
|
|
|
|
database
|
|
.save_thread(plain_id.clone(), plain_thread)
|
|
.await
|
|
.unwrap();
|
|
|
|
// List threads and verify worktree_branch is populated correctly
|
|
let threads = database.list_threads().await.unwrap();
|
|
assert_eq!(threads.len(), 2);
|
|
|
|
let wt_entry = threads
|
|
.iter()
|
|
.find(|t| t.id == worktree_id)
|
|
.expect("should find worktree thread");
|
|
assert_eq!(wt_entry.worktree_branch.as_deref(), Some("zed/agent/bR9kz"));
|
|
|
|
let plain_entry = threads
|
|
.iter()
|
|
.find(|t| t.id == plain_id)
|
|
.expect("should find plain thread");
|
|
assert!(
|
|
plain_entry.worktree_branch.is_none(),
|
|
"plain thread should have no worktree_branch"
|
|
);
|
|
}
|
|
}
|