refactor: remove threads layer, use sessions directly for ACP (#9078)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-05-08 16:10:55 -04:00
committed by GitHub
parent a999f52056
commit ea5802c380
32 changed files with 525 additions and 1922 deletions
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -5,13 +5,13 @@ impl GooseAcpAgent {
&self,
req: AddExtensionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let config: ExtensionConfig = serde_json::from_value(req.config).map_err(|e| {
agent_client_protocol::Error::invalid_params().data(format!("bad config: {e}"))
})?;
let agent = self.get_session_agent(&req.session_id, None).await?;
agent
.add_extension(config, &internal_id)
.add_extension(config, session_id)
.await
.internal_err()?;
Ok(EmptyResponse {})
@@ -21,10 +21,10 @@ impl GooseAcpAgent {
&self,
req: RemoveExtensionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let agent = self.get_session_agent(&req.session_id, None).await?;
agent
.remove_extension(&req.name, &internal_id)
.remove_extension(&req.name, session_id)
.await
.internal_err()?;
Ok(EmptyResponse {})
@@ -114,10 +114,10 @@ impl GooseAcpAgent {
&self,
req: GetSessionExtensionsRequest,
) -> Result<GetSessionExtensionsResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let session = self
.session_manager
.get_session(&internal_id, false)
.get_session(session_id, false)
.await
.internal_err()?;
+2 -2
View File
@@ -5,12 +5,12 @@ impl GooseAcpAgent {
&self,
req: ReadResourceRequest,
) -> Result<ReadResourceResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let agent = self.get_session_agent(&req.session_id, None).await?;
let cancel_token = CancellationToken::new();
let result = agent
.extension_manager
.read_resource(&internal_id, &req.uri, &req.extension_name, cancel_token)
.read_resource(session_id, &req.uri, &req.extension_name, cancel_token)
.await
.internal_err()?;
let result_json = serde_json::to_value(&result).internal_err()?;
+30 -81
View File
@@ -16,20 +16,15 @@ impl GooseAcpAgent {
agent_client_protocol::Error::invalid_params().data("invalid directory path")
);
}
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
self.session_manager
.update(&internal_id)
.update(session_id)
.working_dir(path.clone())
.apply()
.await
.internal_err()?;
self.thread_manager
.update_working_dir(&req.session_id, &working_dir)
.await
.internal_err()?;
if let Some(session) = self.sessions.lock().await.get_mut(&req.session_id) {
if let Some(session) = self.sessions.lock().await.get_mut(session_id) {
match &session.agent {
AgentHandle::Ready(agent) => {
agent.extension_manager.update_working_dir(&path).await;
@@ -47,9 +42,8 @@ impl GooseAcpAgent {
&self,
req: DeleteSessionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
// Delete the thread and all its internal sessions + messages.
self.thread_manager
.delete_thread(&req.session_id)
self.session_manager
.delete_session(&req.session_id)
.await
.internal_err()?;
self.sessions.lock().await.remove(&req.session_id);
@@ -60,17 +54,9 @@ impl GooseAcpAgent {
&self,
req: ExportSessionRequest,
) -> Result<ExportSessionResponse, agent_client_protocol::Error> {
let thread = self
.thread_manager
.get_thread(&req.session_id)
.await
.internal_err()?;
let internal_id = thread.current_session_id.ok_or_else(|| {
agent_client_protocol::Error::internal_error().data("Thread has no internal session")
})?;
let data = self
.session_manager
.export_session(&internal_id)
.export_session(&req.session_id)
.await
.internal_err()?;
Ok(ExportSessionResponse { data })
@@ -82,51 +68,17 @@ impl GooseAcpAgent {
) -> Result<ImportSessionResponse, agent_client_protocol::Error> {
let session = self
.session_manager
.import_session(&req.data, Some(SessionType::Acp))
.import_session(&req.data, None)
.await
.internal_err()?;
// Create a thread for the imported session.
let thread = self
.thread_manager
.create_thread(
Some(session.name.clone()),
None,
Some(session.working_dir.display().to_string()),
)
.await
.internal_err()?;
// Link the internal session to the thread.
self.session_manager
.update(&session.id)
.thread_id(Some(thread.id.clone()))
.apply()
.await
.internal_err()?;
// Copy conversation messages into thread_messages so they appear in the thread.
if let Some(ref conversation) = session.conversation {
for msg in conversation.messages() {
self.thread_manager
.append_message(&thread.id, Some(&session.id), msg)
.await
.internal_err()?;
}
}
// Re-fetch thread to get accurate message_count.
let thread = self
.thread_manager
.get_thread(&thread.id)
.await
.internal_err()?;
let msg_count = session.message_count as u64;
Ok(ImportSessionResponse {
session_id: thread.id,
title: Some(thread.name),
updated_at: Some(thread.updated_at.to_rfc3339()),
message_count: thread.message_count as u64,
session_id: session.id,
title: Some(session.name),
updated_at: Some(session.updated_at.to_rfc3339()),
message_count: msg_count,
})
}
@@ -134,11 +86,12 @@ impl GooseAcpAgent {
&self,
req: UpdateSessionProjectRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let project_id = req.project_id;
self.update_thread_metadata(&req.session_id, move |meta| {
meta.project_id = project_id;
})
.await?;
self.session_manager
.update(&req.session_id)
.project_id(req.project_id)
.apply()
.await
.internal_err()?;
Ok(EmptyResponse {})
}
@@ -146,20 +99,12 @@ impl GooseAcpAgent {
&self,
req: RenameSessionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let title = req.title;
let thread = self
.thread_manager
.update_thread(&req.session_id, Some(title.clone()), Some(true), None)
self.session_manager
.update(&req.session_id)
.user_provided_name(req.title)
.apply()
.await
.map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
if let Some(internal_session_id) = thread.current_session_id {
self.session_manager
.update(&internal_session_id)
.user_provided_name(title)
.apply()
.await
.map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
}
Ok(EmptyResponse {})
}
@@ -167,8 +112,10 @@ impl GooseAcpAgent {
&self,
req: ArchiveSessionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.thread_manager
.archive_thread(&req.session_id)
self.session_manager
.update(&req.session_id)
.archived_at(Some(chrono::Utc::now()))
.apply()
.await
.internal_err()?;
self.sessions.lock().await.remove(&req.session_id);
@@ -179,8 +126,10 @@ impl GooseAcpAgent {
&self,
req: UnarchiveSessionRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.thread_manager
.unarchive_thread(&req.session_id)
self.session_manager
.update(&req.session_id)
.archived_at(None)
.apply()
.await
.internal_err()?;
Ok(EmptyResponse {})
+5 -5
View File
@@ -7,9 +7,9 @@ impl GooseAcpAgent {
&self,
req: GetToolsRequest,
) -> Result<GetToolsResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let agent = self.get_session_agent(&req.session_id, None).await?;
let tools = agent.list_tools(&internal_id, None).await;
let tools = agent.list_tools(session_id, None).await;
let tools_json = tools
.into_iter()
.map(|t| serde_json::to_value(&t))
@@ -22,9 +22,9 @@ impl GooseAcpAgent {
&self,
req: GooseToolCallRequest,
) -> Result<GooseToolCallResponse, agent_client_protocol::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let session_id = &req.session_id;
let agent = self.get_session_agent(&req.session_id, None).await?;
let tools = agent.list_tools(&internal_id, None).await;
let tools = agent.list_tools(session_id, None).await;
let Some(tool) = tools.iter().find(|t| *t.name == req.name) else {
return Err(agent_client_protocol::Error::invalid_params().data("tool not found"));
@@ -52,7 +52,7 @@ impl GooseAcpAgent {
params
};
let ctx = crate::agents::ToolCallContext::new(internal_id, None, None);
let ctx = crate::agents::ToolCallContext::new(session_id.clone(), None, None);
let tool_result = agent
.extension_manager
.dispatch_tool_call(&ctx, tool_call, CancellationToken::new())
+1 -5
View File
@@ -367,11 +367,7 @@ impl Agent {
}
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
let thread_id = session.thread_id.as_deref()?;
let thread_mgr =
crate::session::ThreadManager::new(self.config.session_manager.storage().clone());
let thread = thread_mgr.get_thread(thread_id).await.ok()?;
let project_id = thread.metadata.project_id.as_deref()?;
let project_id = session.project_id.as_deref()?;
let entry = crate::sources::read_project(project_id).ok()?;
let mut parts = Vec::new();
parts.push(format!("# Project: {}", entry.name));
-22
View File
@@ -382,28 +382,6 @@ mod tests {
assert_eq!(manager.session_count().await, 1);
}
#[tokio::test]
async fn test_set_default_provider() {
use crate::providers::testprovider::TestProvider;
let temp_dir = TempDir::new().unwrap();
let manager = create_test_manager(&temp_dir).await;
// Create a test provider for replaying (doesn't need inner provider)
let temp_file = temp_dir.path().join("test_provider.json");
// Create an empty test provider (will fail on actual use but that's ok for this test)
std::fs::write(&temp_file, "{}").unwrap();
let test_provider = TestProvider::new_replaying(temp_file.to_str().unwrap()).unwrap();
manager.set_default_provider(Arc::new(test_provider)).await;
let session = String::from("provider-test");
let _agent = manager.get_or_create_agent(session.clone()).await.unwrap();
assert!(manager.has_session(&session).await);
}
#[tokio::test]
async fn test_eviction_updates_last_used() {
// Test that accessing a session updates its last_used timestamp
-2
View File
@@ -3,7 +3,6 @@ mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod session_manager;
pub mod thread_manager;
pub use diagnostics::{
config_path, generate_diagnostics, get_system_info, latest_llm_log_path,
@@ -13,4 +12,3 @@ pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState,
pub use session_manager::{
Session, SessionInsights, SessionManager, SessionNameUpdate, SessionType, SessionUpdateBuilder,
};
pub use thread_manager::{Thread, ThreadManager, ThreadMetadata};
+188 -76
View File
@@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock};
use tracing::{info, warn};
use utoipa::ToSchema;
pub const CURRENT_SCHEMA_VERSION: i32 = 11;
pub const CURRENT_SCHEMA_VERSION: i32 = 12;
pub const SESSIONS_FOLDER: &str = "sessions";
pub const DB_NAME: &str = "sessions.db";
@@ -82,7 +82,9 @@ pub struct Session {
#[serde(default)]
pub goose_mode: GooseMode,
#[serde(default)]
pub thread_id: Option<String>,
pub archived_at: Option<DateTime<Utc>>,
#[serde(default)]
pub project_id: Option<String>,
}
pub struct SessionUpdateBuilder<'a> {
@@ -105,7 +107,9 @@ pub struct SessionUpdateBuilder<'a> {
provider_name: Option<Option<String>>,
model_config: Option<Option<ModelConfig>>,
goose_mode: Option<GooseMode>,
thread_id: Option<Option<String>>,
archived_at: Option<Option<DateTime<Utc>>>,
project_id: Option<Option<String>>,
}
#[derive(Serialize, ToSchema, Debug)]
@@ -137,7 +141,8 @@ impl<'a> SessionUpdateBuilder<'a> {
provider_name: None,
model_config: None,
goose_mode: None,
thread_id: None,
archived_at: None,
project_id: None,
}
}
@@ -246,8 +251,13 @@ impl<'a> SessionUpdateBuilder<'a> {
self
}
pub fn thread_id(mut self, thread_id: Option<String>) -> Self {
self.thread_id = Some(thread_id);
pub fn archived_at(mut self, archived_at: Option<DateTime<Utc>>) -> Self {
self.archived_at = Some(archived_at);
self
}
pub fn project_id(mut self, project_id: Option<String>) -> Self {
self.project_id = Some(project_id);
self
}
}
@@ -258,7 +268,11 @@ pub struct SessionManager {
#[derive(Debug, Clone)]
pub struct SessionNameUpdate {
pub thread: super::thread_manager::Thread,
pub session_id: String,
pub name: String,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub message_count: usize,
pub user_set_name: bool,
}
impl SessionManager {
@@ -384,21 +398,16 @@ impl SessionManager {
.apply()
.await?;
// Also update the thread name so ACP clients see it via session/list.
if let Some(ref thread_id) = session.thread_id {
let thread_mgr = super::thread_manager::ThreadManager::new(self.storage.clone());
let thread = thread_mgr.get_thread(thread_id).await?;
if !thread.user_set_name && thread.name != name {
let thread = thread_mgr
.update_thread(thread_id, Some(name), Some(false), None)
.await?;
return Ok(Some(SessionNameUpdate { thread }));
}
}
Ok(None)
} else {
Ok(None)
let session = self.get_session(id, false).await?;
return Ok(Some(SessionNameUpdate {
session_id: id.to_string(),
name,
updated_at: session.updated_at,
message_count: session.message_count,
user_set_name: session.user_set_name,
}));
}
Ok(None)
}
pub async fn search_chat_history(
@@ -433,6 +442,22 @@ impl SessionManager {
.update_message_metadata(id, message_id, f)
.await
}
/// Patch `tool_meta` on a specific `ToolRequest` within a stored message.
/// Used to persist LLM-generated tool titles and chain summaries so they
/// survive session reload. Merge-based: existing keys not in `patch` are
/// preserved. No-op if the message or tool_call_id is not found.
pub async fn update_tool_request_meta(
&self,
session_id: &str,
message_id: &str,
tool_call_id: &str,
patch: serde_json::Value,
) -> Result<()> {
self.storage
.update_tool_request_meta(session_id, message_id, tool_call_id, patch)
.await
}
}
pub struct SessionStorage {
@@ -473,7 +498,8 @@ impl Default for Session {
provider_name: None,
model_config: None,
goose_mode: GooseMode::default(),
thread_id: None,
archived_at: None,
project_id: None,
}
}
}
@@ -543,7 +569,8 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_default(),
thread_id: row.try_get("thread_id").ok().flatten(),
archived_at: row.try_get("archived_at").ok(),
project_id: row.try_get("project_id").ok().flatten(),
})
}
}
@@ -644,7 +671,8 @@ impl SessionStorage {
provider_name TEXT,
model_config_json TEXT,
goose_mode TEXT NOT NULL DEFAULT 'auto',
thread_id TEXT
archived_at TIMESTAMP,
project_id TEXT
)
"#,
)
@@ -684,49 +712,6 @@ impl SessionStorage {
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id)")
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'New Chat',
user_set_name BOOLEAN DEFAULT FALSE,
working_dir TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL REFERENCES threads(id),
session_id TEXT,
message_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
created_timestamp INTEGER NOT NULL,
metadata_json TEXT DEFAULT '{}'
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)",
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_message_id ON thread_messages(message_id)")
.execute(pool)
.await?;
crate::providers::inventory::create_tables(pool).await?;
Ok(())
@@ -1075,6 +1060,46 @@ impl SessionStorage {
11 => {
crate::providers::inventory::create_tables_in_tx(tx).await?;
}
12 => {
// Add archived_at, project_id columns to sessions.
let has_archived_at = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'archived_at'",
)
.fetch_one(&mut **tx)
.await?
> 0;
if !has_archived_at {
sqlx::query("ALTER TABLE sessions ADD COLUMN archived_at TIMESTAMP")
.execute(&mut **tx)
.await?;
}
let has_project_id = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'project_id'",
)
.fetch_one(&mut **tx)
.await?
> 0;
if !has_project_id {
sqlx::query("ALTER TABLE sessions ADD COLUMN project_id TEXT")
.execute(&mut **tx)
.await?;
}
// Drop thread tables and thread_id column (no longer used).
sqlx::query("DROP TABLE IF EXISTS thread_messages")
.execute(&mut **tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS threads")
.execute(&mut **tx)
.await?;
sqlx::query("DROP INDEX IF EXISTS idx_sessions_thread")
.execute(&mut **tx)
.await?;
sqlx::query("ALTER TABLE sessions DROP COLUMN thread_id")
.execute(&mut **tx)
.await?;
}
_ => {
anyhow::bail!("Unknown migration version: {}", version);
}
@@ -1136,7 +1161,8 @@ impl SessionStorage {
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json, goose_mode, thread_id
provider_name, model_config_json, goose_mode,
archived_at, project_id
FROM sessions
WHERE id = ?
"#,
@@ -1200,7 +1226,9 @@ impl SessionStorage {
add_update!(builder.provider_name, "provider_name");
add_update!(builder.model_config, "model_config_json");
add_update!(builder.goose_mode, "goose_mode");
add_update!(builder.thread_id, "thread_id");
add_update!(builder.archived_at, "archived_at");
add_update!(builder.project_id, "project_id");
if updates.is_empty() {
return Ok(());
@@ -1269,14 +1297,22 @@ impl SessionStorage {
if let Some(goose_mode) = builder.goose_mode {
q = q.bind(goose_mode.to_string());
}
if let Some(thread_id) = builder.thread_id {
q = q.bind(thread_id);
if let Some(ref archived_at) = builder.archived_at {
q = q.bind(archived_at.as_ref());
}
if let Some(ref project_id) = builder.project_id {
q = q.bind(project_id.as_ref());
}
let pool = self.pool().await?;
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
q = q.bind(&builder.session_id);
q.execute(&mut *tx).await?;
let result = q.execute(&mut *tx).await?;
if result.rows_affected() == 0 {
return Err(anyhow::anyhow!("Session not found: {}", builder.session_id));
}
tx.commit().await?;
Ok(())
@@ -1423,7 +1459,8 @@ impl SessionStorage {
s.total_tokens, s.input_tokens, s.output_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
s.provider_name, s.model_config_json, s.goose_mode, s.thread_id,
s.provider_name, s.model_config_json, s.goose_mode,
s.archived_at, s.project_id,
COUNT(m.id) as message_count
FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id
@@ -1582,15 +1619,15 @@ impl SessionStorage {
.recipe(original_session.recipe)
.user_recipe_values(original_session.user_recipe_values);
// Preserve provider, model config, and goose_mode from original session
if let Some(project_id) = original_session.project_id {
builder = builder.project_id(Some(project_id));
}
if let Some(provider_name) = original_session.provider_name {
builder = builder.provider_name(provider_name);
}
if let Some(model_config) = original_session.model_config {
builder = builder.model_config(model_config);
}
builder = builder.goose_mode(original_session.goose_mode);
builder.apply().await?;
@@ -1680,6 +1717,81 @@ impl SessionStorage {
Ok(())
}
/// Patch `tool_meta` on a specific `ToolRequest` within a stored message's
/// `content_json`. Finds the row(s) with matching `message_id`, scans each
/// row's content for a `ToolRequest` with the given `tool_call_id`, and
/// merges `patch` into its `tool_meta`. Uses `BEGIN IMMEDIATE` so
/// concurrent writers serialize correctly.
async fn update_tool_request_meta(
&self,
session_id: &str,
message_id: &str,
tool_call_id: &str,
patch: serde_json::Value,
) -> Result<()> {
use crate::conversation::message::MessageContent;
let pool = self.pool().await?;
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
let rows = sqlx::query_as::<_, (i64, String)>(
"SELECT id, content_json FROM messages \
WHERE session_id = ? AND message_id = ? \
ORDER BY id ASC",
)
.bind(session_id)
.bind(message_id)
.fetch_all(&mut *tx)
.await?;
for (row_id, content_json) in rows {
let mut content: Vec<MessageContent> = serde_json::from_str(&content_json)?;
let mut found = false;
for block in &mut content {
if let MessageContent::ToolRequest(tr) = block {
if tr.id == tool_call_id {
tr.tool_meta = Some(merge_tool_meta(tr.tool_meta.take(), &patch));
found = true;
break;
}
}
}
if !found {
continue;
}
let updated_json = serde_json::to_string(&content)?;
sqlx::query("UPDATE messages SET content_json = ? WHERE id = ?")
.bind(updated_json)
.bind(row_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(());
}
tx.commit().await?;
Ok(())
}
}
/// Merge a JSON object `patch` into an existing optional object value,
/// preserving keys not present in the patch.
fn merge_tool_meta(
existing: Option<serde_json::Value>,
patch: &serde_json::Value,
) -> serde_json::Value {
let mut base = match existing {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let serde_json::Value::Object(patch_map) = patch {
for (k, v) in patch_map {
base.insert(k.clone(), v.clone());
}
}
serde_json::Value::Object(base)
}
#[cfg(test)]
-947
View File
@@ -1,947 +0,0 @@
use super::session_manager::{role_to_string, SessionStorage};
use crate::conversation::message::{Message, MessageContent};
use anyhow::Result;
use chrono::{DateTime, Utc};
use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub id: String,
pub name: String,
pub user_set_name: bool,
pub working_dir: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub archived_at: Option<DateTime<Utc>>,
pub metadata: ThreadMetadata,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub message_count: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ThreadMetadata {
#[serde(default)]
pub persona_id: Option<String>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default, alias = "model_name")]
pub model_id: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
pub struct ThreadManager {
storage: Arc<SessionStorage>,
}
const THREAD_SELECT: &str = "\
SELECT t.id, t.name, t.user_set_name, t.working_dir, t.created_at, t.updated_at, \
t.archived_at, t.metadata_json, \
(SELECT s.id FROM sessions s WHERE s.thread_id = t.id ORDER BY s.created_at DESC LIMIT 1) as current_session_id, \
(SELECT COUNT(*) FROM thread_messages WHERE thread_id = t.id) as message_count \
FROM threads t";
type ThreadRow = (
String,
String,
bool,
Option<String>,
DateTime<Utc>,
DateTime<Utc>,
Option<DateTime<Utc>>,
String,
Option<String>,
i64,
);
fn thread_from_row(
(
id,
name,
user_set_name,
working_dir,
created_at,
updated_at,
archived_at,
metadata_json,
current_session_id,
message_count,
): ThreadRow,
) -> Result<Thread> {
let metadata: ThreadMetadata = serde_json::from_str(&metadata_json).unwrap_or_default();
Ok(Thread {
id,
name,
user_set_name,
working_dir,
created_at,
updated_at,
archived_at,
metadata,
current_session_id,
message_count,
})
}
impl ThreadManager {
pub fn new(storage: Arc<SessionStorage>) -> Self {
Self { storage }
}
pub async fn create_thread(
&self,
name: Option<String>,
metadata: Option<ThreadMetadata>,
working_dir: Option<String>,
) -> Result<Thread> {
let pool = self.storage.pool().await?;
let id = uuid::Uuid::new_v4().to_string();
let name = name.unwrap_or_else(|| "New Chat".to_string());
let meta = metadata.unwrap_or_default();
let metadata_json = serde_json::to_string(&meta)?;
sqlx::query(
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
)
.bind(&id)
.bind(&name)
.bind(&working_dir)
.bind(&metadata_json)
.execute(pool)
.await?;
self.get_thread(&id).await
}
pub async fn get_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
let sql = format!("{} WHERE t.id = ?", THREAD_SELECT);
let row = sqlx::query_as::<_, ThreadRow>(&sql)
.bind(id)
.fetch_one(pool)
.await?;
thread_from_row(row)
}
pub async fn update_thread(
&self,
id: &str,
name: Option<String>,
user_set_name: Option<bool>,
metadata: Option<ThreadMetadata>,
) -> Result<Thread> {
let pool = self.storage.pool().await?;
let mut sets = Vec::new();
if name.is_some() {
sets.push("name = ?");
sets.push("user_set_name = ?");
}
if metadata.is_some() {
sets.push("metadata_json = ?");
}
if !sets.is_empty() {
let sql = format!(
"UPDATE threads SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
if let Some(ref n) = name {
q = q.bind(n);
q = q.bind(user_set_name.unwrap_or(true));
}
if let Some(ref meta) = metadata {
q = q.bind(serde_json::to_string(meta)?);
}
q = q.bind(id);
q.execute(pool).await?;
}
self.get_thread(id).await
}
pub async fn list_threads(&self, include_archived: bool) -> Result<Vec<Thread>> {
let pool = self.storage.pool().await?;
let sql = if include_archived {
format!("{} ORDER BY t.updated_at DESC", THREAD_SELECT)
} else {
format!(
"{} WHERE t.archived_at IS NULL ORDER BY t.updated_at DESC",
THREAD_SELECT
)
};
let rows = sqlx::query_as::<_, ThreadRow>(&sql).fetch_all(pool).await?;
rows.into_iter().map(thread_from_row).collect()
}
pub async fn archive_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
sqlx::query("UPDATE threads SET archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
self.get_thread(id).await
}
pub async fn unarchive_thread(&self, id: &str) -> Result<Thread> {
let pool = self.storage.pool().await?;
sqlx::query(
"UPDATE threads SET archived_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
self.get_thread(id).await
}
pub async fn update_metadata(
&self,
id: &str,
f: impl FnOnce(&mut ThreadMetadata),
) -> Result<Thread> {
let thread = self.get_thread(id).await?;
let mut meta = thread.metadata;
f(&mut meta);
self.update_thread(id, None, None, Some(meta)).await
}
pub async fn update_working_dir(&self, id: &str, working_dir: &str) -> Result<()> {
let pool = self.storage.pool().await?;
sqlx::query(
"UPDATE threads SET working_dir = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(working_dir)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_thread(&self, id: &str) -> Result<()> {
let pool = self.storage.pool().await?;
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM thread_messages WHERE thread_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE thread_id = ?)",
)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM sessions WHERE thread_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM threads WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn append_message(
&self,
thread_id: &str,
session_id: Option<&str>,
message: &Message,
) -> Result<Message> {
let pool = self.storage.pool().await?;
let role_str = role_to_string(&message.role);
let metadata_json = serde_json::to_string(&message.metadata)?;
// When the incoming message is text-only, try to coalesce it with the
// last stored row if that row has the same role and is also text-only.
// This avoids storing one row per streaming token while keeping the UI
// streaming path unchanged (callers still forward every chunk).
if message.has_only_text_content() && !message.content.is_empty() {
let new_text = message.as_concat_text();
let maybe_last = sqlx::query_as::<_, (i64, String, String, String, String)>(
"SELECT id, message_id, role, content_json, metadata_json \
FROM thread_messages \
WHERE thread_id = ? \
ORDER BY id DESC LIMIT 1",
)
.bind(thread_id)
.fetch_optional(pool)
.await?;
if let Some((
row_id,
existing_msg_id,
last_role,
last_content_json,
last_metadata_json,
)) = maybe_last
{
if last_role == role_str
&& last_metadata_json == metadata_json
&& is_text_only_json(&last_content_json)
{
// Append text into the existing row's single text element.
let updated_json = append_text_json(&last_content_json, &new_text)?;
sqlx::query("UPDATE thread_messages SET content_json = ? WHERE id = ?")
.bind(&updated_json)
.bind(row_id)
.execute(pool)
.await?;
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(thread_id)
.execute(pool)
.await?;
let mut stored = message.clone();
stored.id = Some(existing_msg_id);
return Ok(stored);
}
}
}
// Default path: insert a new row.
let content_json = serde_json::to_string(&message.content)?;
let message_id = message
.id
.clone()
.unwrap_or_else(|| format!("tmsg_{}", uuid::Uuid::new_v4()));
sqlx::query(
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(thread_id)
.bind(session_id)
.bind(&message_id)
.bind(role_str)
.bind(&content_json)
.bind(message.created)
.bind(&metadata_json)
.execute(pool)
.await?;
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(thread_id)
.execute(pool)
.await?;
let mut stored = message.clone();
stored.id = Some(message_id);
Ok(stored)
}
pub async fn fork_thread(&self, source_thread_id: &str) -> Result<Thread> {
let source = self.get_thread(source_thread_id).await?;
let pool = self.storage.pool().await?;
let new_id = uuid::Uuid::new_v4().to_string();
let name = format!("Fork of {}", source.name);
let metadata_json = serde_json::to_string(&source.metadata)?;
sqlx::query(
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
)
.bind(&new_id)
.bind(&name)
.bind(&source.working_dir)
.bind(&metadata_json)
.execute(pool)
.await?;
// Copy all thread messages
sqlx::query(
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) \
SELECT ?, session_id, 'tmsg_' || hex(randomblob(16)), role, content_json, created_timestamp, metadata_json \
FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
)
.bind(&new_id)
.bind(source_thread_id)
.execute(pool)
.await?;
self.get_thread(&new_id).await
}
/// Merge a JSON object patch into the `tool_meta` of the `ToolRequest` whose
/// `id == tool_call_id` inside the message identified by `(thread_id,
/// message_id)`. Existing keys in `tool_meta` are preserved.
///
/// No-ops (returns `Ok(())`) if the row containing the tool request can't
/// be found — callers (e.g. async title tasks) treat persistence as
/// best-effort.
///
/// `message_id` is used as a coarse filter, but multiple `thread_messages`
/// rows can share the same `message_id` when the agent splits a single
/// LLM response (e.g. text + tool_request) into separate
/// `AgentEvent::Message` events. We disambiguate by walking the matching
/// rows and picking the one whose content actually contains a
/// `ToolRequest` with `tool_call_id`, then update only that row by its
/// auto-incremented primary key. Without this, the title for the first
/// tool in such a split message never persists, because `fetch_optional`
/// returns the text-only row first and finds no matching tool call.
pub async fn update_tool_request_meta(
&self,
thread_id: &str,
message_id: &str,
tool_call_id: &str,
patch: serde_json::Value,
) -> Result<()> {
let pool = self.storage.pool().await?;
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
let rows = sqlx::query_as::<_, (i64, String)>(
"SELECT id, content_json FROM thread_messages \
WHERE thread_id = ? AND message_id = ? \
ORDER BY id ASC",
)
.bind(thread_id)
.bind(message_id)
.fetch_all(&mut *tx)
.await?;
for (row_id, content_json) in rows {
let mut content: Vec<MessageContent> = serde_json::from_str(&content_json)?;
let mut found = false;
for block in &mut content {
if let MessageContent::ToolRequest(tr) = block {
if tr.id == tool_call_id {
tr.tool_meta = Some(merge_tool_meta(tr.tool_meta.take(), &patch));
found = true;
break;
}
}
}
if !found {
continue;
}
let updated_json = serde_json::to_string(&content)?;
sqlx::query("UPDATE thread_messages SET content_json = ? WHERE id = ?")
.bind(updated_json)
.bind(row_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(());
}
tx.commit().await?;
Ok(())
}
pub async fn list_messages(&self, thread_id: &str) -> Result<Vec<Message>> {
let pool = self.storage.pool().await?;
let rows = sqlx::query_as::<_, (Option<String>, String, Option<String>, String, i64, String)>(
"SELECT message_id, role, session_id, content_json, created_timestamp, metadata_json FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
)
.bind(thread_id)
.fetch_all(pool)
.await?;
let mut messages = Vec::new();
for (message_id, role_str, _session_id, content_json, created_timestamp, metadata_json) in
rows
{
let role = match role_str.as_str() {
"user" => Role::User,
"assistant" => Role::Assistant,
_ => continue,
};
let content = serde_json::from_str(&content_json)?;
let metadata = serde_json::from_str(&metadata_json).unwrap_or_default();
let mut msg = Message::new(role, created_timestamp, content);
msg.metadata = metadata;
if let Some(id) = message_id {
msg = msg.with_id(id);
}
messages.push(msg);
}
Ok(messages)
}
}
/// Check whether a `content_json` string represents a single text-only element.
/// Avoids a full deserialize by inspecting the JSON structure directly.
fn is_text_only_json(content_json: &str) -> bool {
let Ok(items) = serde_json::from_str::<Vec<serde_json::Value>>(content_json) else {
return false;
};
items.len() == 1
&& items[0].get("type").and_then(|v| v.as_str()) == Some("text")
&& items[0].get("text").is_some()
}
/// Append `new_text` to the single text element in a text-only `content_json` array.
fn append_text_json(content_json: &str, new_text: &str) -> anyhow::Result<String> {
let mut items: Vec<serde_json::Value> = serde_json::from_str(content_json)?;
if let Some(text_val) = items.get_mut(0).and_then(|v| v.get_mut("text")) {
let existing = text_val.as_str().unwrap_or("");
*text_val = serde_json::Value::String(format!("{}{}", existing, new_text));
}
Ok(serde_json::to_string(&items)?)
}
/// Merge a JSON object `patch` into an existing optional object value,
/// preserving keys not present in the patch. Non-object values are replaced.
fn merge_tool_meta(
existing: Option<serde_json::Value>,
patch: &serde_json::Value,
) -> serde_json::Value {
let mut base = match existing {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let serde_json::Value::Object(patch_map) = patch {
for (k, v) in patch_map {
base.insert(k.clone(), v.clone());
}
}
serde_json::Value::Object(base)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{
Message, MessageContent, ToolRequest, TOOL_META_CHAIN_SUMMARY_KEY,
TOOL_META_EXTERNAL_DISPATCH_KEY, TOOL_META_TITLE_KEY,
};
use crate::session::SessionManager;
use rmcp::model::CallToolRequestParams;
use tempfile::TempDir;
fn assistant_message_with_tool_request(
tool_id: &str,
tool_meta: Option<serde_json::Value>,
) -> Message {
let tool_request = ToolRequest {
id: tool_id.to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta,
};
Message::new(
Role::Assistant,
chrono::Utc::now().timestamp_millis(),
vec![MessageContent::ToolRequest(tool_request)],
)
}
async fn fresh_thread_manager(temp: &TempDir) -> Arc<ThreadManager> {
let session_manager = SessionManager::new(temp.path().to_path_buf());
Arc::new(ThreadManager::new(session_manager.storage().clone()))
}
#[tokio::test]
async fn update_tool_request_meta_sets_title_when_missing() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let stored = mgr
.append_message(
&thread.id,
None,
&assistant_message_with_tool_request("tc-1", None),
)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
mgr.update_tool_request_meta(
&thread.id,
&message_id,
"tc-1",
serde_json::json!({ TOOL_META_TITLE_KEY: "reading config" }),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let req = match &messages[0].content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
assert_eq!(req.persisted_title(), Some("reading config"));
}
#[tokio::test]
async fn update_tool_request_meta_preserves_existing_keys() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let stored = mgr
.append_message(
&thread.id,
None,
&assistant_message_with_tool_request(
"tc-1",
Some(serde_json::json!({ TOOL_META_EXTERNAL_DISPATCH_KEY: true })),
),
)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
mgr.update_tool_request_meta(
&thread.id,
&message_id,
"tc-1",
serde_json::json!({ TOOL_META_TITLE_KEY: "running commands" }),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let req = match &messages[0].content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
assert!(
req.is_externally_dispatched(),
"external_dispatch key should be preserved across the merge"
);
assert_eq!(req.persisted_title(), Some("running commands"));
}
#[tokio::test]
async fn update_tool_request_meta_overwrites_existing_value() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let stored = mgr
.append_message(
&thread.id,
None,
&assistant_message_with_tool_request(
"tc-1",
Some(serde_json::json!({ TOOL_META_TITLE_KEY: "old" })),
),
)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
mgr.update_tool_request_meta(
&thread.id,
&message_id,
"tc-1",
serde_json::json!({ TOOL_META_TITLE_KEY: "new" }),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let req = match &messages[0].content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
assert_eq!(req.persisted_title(), Some("new"));
}
#[tokio::test]
async fn update_tool_request_meta_no_op_for_unknown_message() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
mgr.update_tool_request_meta(
&thread.id,
"missing-message-id",
"tc-1",
serde_json::json!({ TOOL_META_TITLE_KEY: "x" }),
)
.await
.expect("missing message must be a no-op, not an error");
}
#[tokio::test]
async fn update_tool_request_meta_no_op_for_unknown_tool_call() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let stored = mgr
.append_message(
&thread.id,
None,
&assistant_message_with_tool_request("tc-1", None),
)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
mgr.update_tool_request_meta(
&thread.id,
&message_id,
"tc-other",
serde_json::json!({ TOOL_META_TITLE_KEY: "x" }),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let req = match &messages[0].content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
assert!(
req.persisted_title().is_none(),
"no-match must leave tool_meta untouched"
);
}
#[tokio::test]
async fn update_tool_request_meta_targets_correct_row_when_message_id_is_shared() {
// Regression for "first tool call in a chain consistently shows the
// deterministic title on reload." Bedrock/Anthropic-style streaming
// produces a single LLM message id (e.g. `msg_bdrk_…`) but the agent
// splits it across multiple `AgentEvent::Message` events — one for
// text, one for the trailing tool_request — and `append_message`
// writes a separate row per event. Both rows end up with the SAME
// `message_id`. `fetch_optional` returned the text-only row first and
// the title never persisted.
use crate::conversation::message::ToolRequest;
use rmcp::model::CallToolRequestParams;
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let shared_id = "msg_bdrk_shared".to_string();
let mut text_only = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp_millis(),
vec![MessageContent::text(
"Let me look at the project structure.",
)],
);
text_only.id = Some(shared_id.clone());
let stored_text = mgr
.append_message(&thread.id, None, &text_only)
.await
.unwrap();
assert_eq!(stored_text.id.as_deref(), Some(shared_id.as_str()));
let mut tool_message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp_millis(),
vec![MessageContent::ToolRequest(ToolRequest {
id: "toolu_tree".to_string(),
tool_call: Ok(CallToolRequestParams::new("tree")),
metadata: None,
tool_meta: None,
})],
);
tool_message.id = Some(shared_id.clone());
let stored_tool = mgr
.append_message(&thread.id, None, &tool_message)
.await
.unwrap();
assert_eq!(stored_tool.id.as_deref(), Some(shared_id.as_str()));
mgr.update_tool_request_meta(
&thread.id,
&shared_id,
"toolu_tree",
serde_json::json!({ TOOL_META_TITLE_KEY: "exploring project structure" }),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
assert_eq!(messages.len(), 2, "two distinct rows must be preserved");
let text_msg = &messages[0];
let tool_msg = &messages[1];
assert!(
matches!(&text_msg.content[0], MessageContent::Text(_)),
"first row must remain text-only and untouched",
);
let tr = match &tool_msg.content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request in second row"),
};
assert_eq!(
tr.persisted_title(),
Some("exploring project structure"),
"title must land on the row that actually contains the tool call",
);
}
#[tokio::test]
async fn update_tool_request_meta_serializes_concurrent_writes_preserving_all_keys() {
// Regression for "occasional bad replay" when multiple persist tasks
// (per-tool title for tc-1, per-tool title for tc-2, chain summary on
// tc-1) race against each other for the same row's tool_meta. They
// must serialize via BEGIN IMMEDIATE and merge rather than clobber.
use crate::conversation::message::ToolRequest;
use rmcp::model::CallToolRequestParams;
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp_millis(),
vec![
MessageContent::ToolRequest(ToolRequest {
id: "tc-1".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: None,
}),
MessageContent::ToolRequest(ToolRequest {
id: "tc-2".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: None,
}),
],
);
let stored = mgr
.append_message(&thread.id, None, &message)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
let m1 = mgr.clone();
let t1 = thread.id.clone();
let mid1 = message_id.clone();
let h1 = tokio::spawn(async move {
m1.update_tool_request_meta(
&t1,
&mid1,
"tc-1",
serde_json::json!({ TOOL_META_TITLE_KEY: "ran shell command" }),
)
.await
.unwrap();
});
let m2 = mgr.clone();
let t2 = thread.id.clone();
let mid2 = message_id.clone();
let h2 = tokio::spawn(async move {
m2.update_tool_request_meta(
&t2,
&mid2,
"tc-2",
serde_json::json!({ TOOL_META_TITLE_KEY: "ran another shell command" }),
)
.await
.unwrap();
});
let m3 = mgr.clone();
let t3 = thread.id.clone();
let mid3 = message_id.clone();
let h3 = tokio::spawn(async move {
m3.update_tool_request_meta(
&t3,
&mid3,
"tc-1",
serde_json::json!({
TOOL_META_CHAIN_SUMMARY_KEY: { "summary": "inspected codebase", "count": 2 },
}),
)
.await
.unwrap();
});
h1.await.unwrap();
h2.await.unwrap();
h3.await.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let content = &messages[0].content;
let tc1 = match &content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
let tc2 = match &content[1] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
assert_eq!(
tc1.persisted_title(),
Some("ran shell command"),
"concurrent writes must not drop tc-1's title",
);
let chain_summary = tc1
.persisted_chain_summary()
.expect("tc-1 must keep its chain summary");
assert_eq!(chain_summary.summary, "inspected codebase");
assert_eq!(chain_summary.count, 2);
assert_eq!(
tc2.persisted_title(),
Some("ran another shell command"),
"concurrent writes must not drop tc-2's title",
);
}
#[tokio::test]
async fn update_tool_request_meta_persists_chain_summary_object() {
let temp = TempDir::new().unwrap();
let mgr = fresh_thread_manager(&temp).await;
let thread = mgr.create_thread(None, None, None).await.unwrap();
let stored = mgr
.append_message(
&thread.id,
None,
&assistant_message_with_tool_request(
"tc-1",
Some(serde_json::json!({ TOOL_META_TITLE_KEY: "first step" })),
),
)
.await
.unwrap();
let message_id = stored.id.clone().unwrap();
mgr.update_tool_request_meta(
&thread.id,
&message_id,
"tc-1",
serde_json::json!({
TOOL_META_CHAIN_SUMMARY_KEY: { "summary": "applied dark mode polish", "count": 4 },
}),
)
.await
.unwrap();
let messages = mgr.list_messages(&thread.id).await.unwrap();
let req = match &messages[0].content[0] {
MessageContent::ToolRequest(r) => r,
_ => panic!("expected tool request"),
};
let chain = req
.persisted_chain_summary()
.expect("chain summary should be present");
assert_eq!(chain.summary, "applied dark mode polish");
assert_eq!(chain.count, 4);
assert_eq!(req.persisted_title(), Some("first step"));
}
}
@@ -108,6 +108,9 @@ pub async fn run_list_sessions<C: Connection>() {
if let Some(ref mut meta) = s.meta {
assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some());
meta.remove("createdAt");
// Provider/model metadata varies by test fixture; not relevant here.
meta.remove("providerId");
meta.remove("modelId");
}
}
let mut expected_meta = serde_json::Map::new();
@@ -1,197 +0,0 @@
use goose::conversation::message::Message;
use goose::session::session_manager::SessionStorage;
use goose::session::thread_manager::ThreadManager;
use rmcp::model::CallToolRequestParams;
use std::sync::Arc;
use tempfile::TempDir;
async fn setup() -> (ThreadManager, TempDir) {
let tmp = TempDir::new().unwrap();
let storage = SessionStorage::create(tmp.path()).await.unwrap();
let tm = ThreadManager::new(Arc::new(storage));
(tm, tmp)
}
#[tokio::test]
async fn consecutive_text_chunks_are_coalesced() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
// Simulate streaming: three consecutive assistant text chunks.
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Hello"),
)
.await
.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text(" world"),
)
.await
.unwrap();
tm.append_message(&thread.id, Some("s1"), &Message::assistant().with_text("!"))
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
assert_eq!(messages.len(), 1, "should coalesce into a single row");
assert_eq!(messages[0].as_concat_text(), "Hello world!");
}
#[tokio::test]
async fn role_change_prevents_coalescing() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
tm.append_message(&thread.id, Some("s1"), &Message::user().with_text("Hi"))
.await
.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Hey"),
)
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
assert_eq!(messages.len(), 2, "different roles should not coalesce");
assert_eq!(messages[0].as_concat_text(), "Hi");
assert_eq!(messages[1].as_concat_text(), "Hey");
}
#[tokio::test]
async fn non_text_content_breaks_coalescing() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
// Text, then tool request, then more text — should be 3 rows.
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Let me check"),
)
.await
.unwrap();
let tool_msg = Message::assistant().with_tool_request(
"call_1",
Ok(CallToolRequestParams::new("shell").with_arguments(
serde_json::json!({"command": "ls"})
.as_object()
.unwrap()
.clone(),
)),
);
tm.append_message(&thread.id, Some("s1"), &tool_msg)
.await
.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Done"),
)
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
assert_eq!(messages.len(), 3, "tool request should break coalescing");
assert_eq!(messages[0].as_concat_text(), "Let me check");
assert_eq!(messages[2].as_concat_text(), "Done");
}
#[tokio::test]
async fn text_after_tool_response_not_coalesced_with_tool() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
// A tool request message (non-text) followed by text — should not coalesce.
let tool_msg = Message::assistant().with_tool_request(
"call_1",
Ok(CallToolRequestParams::new("shell").with_arguments(
serde_json::json!({"command": "ls"})
.as_object()
.unwrap()
.clone(),
)),
);
tm.append_message(&thread.id, Some("s1"), &tool_msg)
.await
.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Result"),
)
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
assert_eq!(
messages.len(),
2,
"text should not coalesce with non-text predecessor"
);
}
#[tokio::test]
async fn empty_message_not_coalesced() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Hello"),
)
.await
.unwrap();
// An empty assistant message (no content items).
let empty = Message::assistant();
tm.append_message(&thread.id, Some("s1"), &empty)
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
// Empty message should be inserted as a new row (not coalesced).
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].as_concat_text(), "Hello");
}
#[tokio::test]
async fn metadata_change_prevents_coalescing() {
let (tm, _tmp) = setup().await;
let thread = tm.create_thread(None, None, None).await.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text("Visible"),
)
.await
.unwrap();
tm.append_message(
&thread.id,
Some("s1"),
&Message::assistant().with_text(" hidden").agent_only(),
)
.await
.unwrap();
let messages = tm.list_messages(&thread.id).await.unwrap();
assert_eq!(
messages.len(),
2,
"metadata boundary should break coalescing"
);
assert!(messages[0].metadata.user_visible);
assert!(!messages[1].metadata.user_visible);
assert_eq!(messages[0].as_concat_text(), "Visible");
assert_eq!(messages[1].as_concat_text(), " hidden");
}
+9 -4
View File
@@ -7720,6 +7720,11 @@
"format": "int32",
"nullable": true
},
"archived_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"conversation": {
"allOf": [
{
@@ -7766,6 +7771,10 @@
"format": "int32",
"nullable": true
},
"project_id": {
"type": "string",
"nullable": true
},
"provider_name": {
"type": "string",
"nullable": true
@@ -7785,10 +7794,6 @@
"session_type": {
"$ref": "#/components/schemas/SessionType"
},
"thread_id": {
"type": "string",
"nullable": true
},
"total_tokens": {
"type": "integer",
"format": "int32",
+2 -1
View File
@@ -1256,6 +1256,7 @@ export type Session = {
accumulated_input_tokens?: number | null;
accumulated_output_tokens?: number | null;
accumulated_total_tokens?: number | null;
archived_at?: string | null;
conversation?: Conversation | null;
created_at: string;
extension_data: ExtensionData;
@@ -1266,11 +1267,11 @@ export type Session = {
model_config?: ModelConfig | null;
name: string;
output_tokens?: number | null;
project_id?: string | null;
provider_name?: string | null;
recipe?: Recipe | null;
schedule_id?: string | null;
session_type?: SessionType;
thread_id?: string | null;
total_tokens?: number | null;
updated_at: string;
user_recipe_values?: {
@@ -80,11 +80,7 @@ describe("useChat compaction", () => {
await result.current.compactConversation();
});
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
const messages = useChatStore.getState().messagesBySession["session-1"];
@@ -121,35 +117,6 @@ describe("useChat compaction", () => {
);
});
it("prepares and compacts the override persona session", async () => {
let preparedPersonaId: string | undefined;
const ensurePrepared = vi.fn(async (personaId?: string) => {
preparedPersonaId = personaId;
return undefined;
});
const { result } = renderHook(() =>
useChat(
"session-1",
undefined,
undefined,
{ id: "persona-b", name: "Persona B" },
{ ensurePrepared },
),
);
await act(async () => {
await result.current.compactConversation({ id: "persona-a" });
});
expect(ensurePrepared).toHaveBeenCalledWith("persona-a");
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact", {
personaId: "persona-a",
});
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
expect(preparedPersonaId).toBe("persona-a");
});
it("blocks new sends while compaction is in flight", async () => {
const compactDeferred = createDeferredPromise();
mockAcpSendMessage.mockImplementation(
@@ -174,11 +141,7 @@ describe("useChat compaction", () => {
});
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
expect(
useChatStore.getState().messagesBySession["session-1"],
).toBeUndefined();
@@ -214,11 +177,7 @@ describe("useChat compaction", () => {
});
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"/compact",
undefined,
);
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
expect(mockAcpLoadSession).not.toHaveBeenCalled();
expect(
useChatStore.getState().getSessionRuntime("session-1").chatState,
@@ -1,96 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import { clearReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args),
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
}));
import { useChat } from "../useChat";
describe("useChat persona preparation", () => {
beforeEach(() => {
mockAcpSendMessage.mockReset();
mockAcpCancelSession.mockReset();
mockAcpLoadSession.mockReset();
clearReplayBuffer("session-1");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: null,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkspaceBySession: {},
});
useAgentStore.setState({
personas: [
{
id: "persona-a",
displayName: "Persona A",
systemPrompt: "",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
{
id: "persona-b",
displayName: "Persona B",
systemPrompt: "",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
],
personasLoading: false,
agents: [],
agentsLoading: false,
activeAgentId: null,
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpCancelSession.mockResolvedValue(true);
mockAcpLoadSession.mockResolvedValue(undefined);
});
it("prepares the override persona before prompting", async () => {
const ensurePrepared = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useChat(
"session-1",
undefined,
undefined,
{ id: "persona-a", name: "Persona A" },
{ ensurePrepared },
),
);
await act(async () => {
await result.current.sendMessage("Hello", { id: "persona-b" });
});
expect(ensurePrepared).toHaveBeenCalledWith("persona-b");
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
personaId: "persona-b",
personaName: "Persona B",
images: undefined,
});
});
});
+2 -6
View File
@@ -268,8 +268,7 @@ export function useChat(
...(sendOptions?.assistantPrompt
? { assistantPrompt: sendOptions.assistantPrompt }
: {}),
personaId: effectivePersonaInfo?.id,
personaName: effectivePersonaInfo?.name,
images: images?.map(
(img) => [img.base64, img.mimeType] as [string, string],
),
@@ -415,10 +414,7 @@ export function useChat(
clearReplayBuffer(sessionId);
try {
const sendOptions = effectivePersonaInfo?.id
? { personaId: effectivePersonaInfo.id }
: undefined;
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER);
// Command responses are streamed via prompt notifications, but the ACP
// layer does not currently forward history replacement events. Drop those
@@ -108,7 +108,7 @@ export function useChatSessionController({
const selectedPersonaId =
pendingPersonaId !== undefined
? pendingPersonaId
: (session?.personaId ?? null);
: (session?.agentId ?? null);
const selectedPersona = personas.find(
(persona) => persona.id === selectedPersonaId,
);
@@ -378,7 +378,7 @@ export function useChatSessionController({
}
useChatSessionStore
.getState()
.patchSession(sessionId, { personaId: personaId ?? undefined });
.patchSession(sessionId, { agentId: personaId ?? undefined });
},
[
handleProviderChange,
@@ -399,7 +399,7 @@ export function useChatSessionController({
if (sessionId) {
useChatSessionStore
.getState()
.patchSession(sessionId, { personaId: undefined });
.patchSession(sessionId, { agentId: undefined });
} else {
setPendingPersonaId(undefined);
}
@@ -595,7 +595,7 @@ export function useChatSessionController({
const nextPersonaId =
pendingPersonaId !== undefined
? (pendingPersonaId ?? undefined)
: session?.personaId;
: session?.agentId;
const nextProjectId =
pendingProjectId !== undefined
? pendingProjectId
@@ -610,10 +610,10 @@ export function useChatSessionController({
const patch: {
providerId?: string;
personaId?: string | undefined;
agentId?: string | undefined;
projectId?: string | null;
modelId?: string | undefined;
modelName?: string | undefined;
projectId?: string | null;
} = {};
if (hasPendingProvider) {
@@ -622,7 +622,7 @@ export function useChatSessionController({
patch.modelName = undefined;
}
if (hasPendingPersona) {
patch.personaId = nextPersonaId;
patch.agentId = nextPersonaId;
}
if (hasPendingProject) {
patch.projectId = nextProjectId ?? null;
@@ -690,7 +690,7 @@ export function useChatSessionController({
pendingQueuedMessage,
prepareCurrentSession,
selectedProvider,
session?.personaId,
session?.agentId,
session?.projectId,
sessionId,
]);
@@ -61,7 +61,7 @@ describe("chatSessionStore", () => {
title: "New Chat",
providerId: "openai",
projectId: "project-1",
personaId: "persona-1",
agentId: "persona-1",
modelId: "gpt-4.1",
modelName: "GPT-4.1",
workingDir: "/tmp/project",
@@ -72,7 +72,6 @@ describe("chatSessionStore", () => {
"/tmp/project",
{
projectId: "project-1",
personaId: "persona-1",
modelId: "gpt-4.1",
},
);
@@ -81,7 +80,7 @@ describe("chatSessionStore", () => {
title: "New Chat",
projectId: "project-1",
providerId: "openai",
personaId: "persona-1",
agentId: "persona-1",
modelId: "gpt-4.1",
modelName: "GPT-4.1",
workingDir: "/tmp/project",
@@ -146,7 +145,6 @@ describe("chatSessionStore", () => {
workingDir: "/tmp/project-123",
projectId: "project-123",
providerId: "anthropic",
personaId: "persona-1",
modelId: "claude-sonnet-4",
},
]);
@@ -157,7 +155,6 @@ describe("chatSessionStore", () => {
expect(session.title).toBe("Renamed Chat");
expect(session.projectId).toBe("project-123");
expect(session.providerId).toBe("anthropic");
expect(session.personaId).toBe("persona-1");
expect(session.createdAt).toBe("2026-03-31");
expect(session.updatedAt).toBe("2026-04-02");
expect(session.messageCount).toBe(7);
@@ -19,7 +19,7 @@ export interface ChatSession {
title: string;
projectId?: string | null;
providerId?: string;
personaId?: string;
agentId?: string;
modelId?: string;
modelName?: string;
workingDir?: string | null;
@@ -66,7 +66,7 @@ interface CreateSessionOpts {
title?: string;
projectId?: string;
providerId?: string;
personaId?: string;
agentId?: string;
workingDir?: string;
modelId?: string;
modelName?: string;
@@ -100,7 +100,6 @@ function acpSessionToChatSession(session: AcpSessionInfo): ChatSession {
title: normalizeAcpTitle(session.title) ?? "Untitled",
projectId: session.projectId ?? undefined,
providerId: session.providerId ?? undefined,
personaId: session.personaId ?? undefined,
modelId: session.modelId ?? undefined,
workingDir: session.workingDir ?? undefined,
createdAt: session.createdAt ?? session.updatedAt ?? now,
@@ -123,7 +122,6 @@ export function sessionToChatSession(session: Session): ChatSession {
title: session.title,
projectId: session.projectId,
providerId: session.providerId,
personaId: session.personaId,
modelId: session.modelId,
modelName: session.modelName,
workingDir: session.workingDir,
@@ -150,7 +148,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
const now = new Date().toISOString();
const providerId = opts.providerId ?? "goose";
const { sessionId } = await acpCreateSession(providerId, opts.workingDir, {
personaId: opts.personaId,
modelId: opts.modelId,
projectId: opts.projectId,
});
@@ -159,7 +156,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
title: opts.title ?? DEFAULT_CHAT_TITLE,
projectId: opts.projectId,
providerId,
personaId: opts.personaId,
agentId: opts.agentId,
modelId: opts.modelId,
modelName: opts.modelName,
workingDir: opts.workingDir,
@@ -30,7 +30,7 @@ describe("buildSessionSearchResults", () => {
makeSession({
id: "session-2",
title: "Builder notes",
personaId: "persona-1",
agentId: "persona-1",
updatedAt: "2026-04-09T12:00:00Z",
}),
];
@@ -22,7 +22,7 @@ function makeSession(
describe("filterSessions", () => {
const sessions: ChatSession[] = [
makeSession({ id: "1", title: "Fix sidebar bug", personaId: "p1" }),
makeSession({ id: "1", title: "Fix sidebar bug", agentId: "p1" }),
makeSession({
id: "2",
title: "Add pagination",
@@ -23,8 +23,8 @@ function buildSearchableString(
parts.push(session.title);
}
if (session.personaId) {
const name = resolvers.getPersonaName(session.personaId);
if (session.agentId) {
const name = resolvers.getPersonaName(session.agentId);
if (name) parts.push(name);
}
@@ -236,8 +236,8 @@ export function SessionHistoryView({
title={result.session.title}
updatedAt={result.session.updatedAt}
personaName={
result.session.personaId
? getPersonaName(result.session.personaId)
result.session.agentId
? getPersonaName(result.session.agentId)
: undefined
}
projectName={
@@ -299,8 +299,8 @@ export function SessionHistoryView({
title={session.title}
updatedAt={session.updatedAt}
personaName={
session.personaId
? getPersonaName(session.personaId)
session.agentId
? getPersonaName(session.agentId)
: undefined
}
projectName={
@@ -41,8 +41,8 @@ export function SidebarSearchResults({
session.title,
t("common:session.defaultTitle"),
);
const personaName = session.personaId
? getPersonaName(session.personaId)
const personaName = session.agentId
? getPersonaName(session.agentId)
: undefined;
const projectName = session.projectId
? getProjectName(session.projectId)
@@ -69,7 +69,6 @@ describe("acpCreateSession", () => {
await expect(
acpCreateSession("openai", "/tmp/project", {
projectId: "project-1",
personaId: "persona-1",
modelId: "gpt-4.1",
}),
).resolves.toEqual({ sessionId: "acp-session-1" });
@@ -78,7 +77,6 @@ describe("acpCreateSession", () => {
"/tmp/project",
"openai",
"project-1",
"persona-1",
);
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockSetProvider).toHaveBeenCalledWith("acp-session-1", "openai");
+2 -12
View File
@@ -21,14 +21,11 @@ export interface AcpProvider {
export interface AcpSendMessageOptions {
systemPrompt?: string;
assistantPrompt?: string;
personaId?: string;
personaName?: string;
/** Image attachments as [base64Data, mimeType] pairs. */
images?: [string, string][];
}
export interface AcpCreateSessionOptions {
personaId?: string;
projectId?: string;
modelId?: string | null;
}
@@ -79,7 +76,7 @@ export async function acpSendMessage(
prompt: string,
options: AcpSendMessageOptions = {},
): Promise<void> {
const { systemPrompt, assistantPrompt, personaId, images } = options;
const { systemPrompt, assistantPrompt, images } = options;
const sid = sessionId.slice(0, 8);
const tStart = performance.now();
@@ -116,14 +113,8 @@ export async function acpSendMessage(
`[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`,
);
const tPrompt = performance.now();
const meta: Record<string, unknown> = {};
if (personaId) meta.personaId = personaId;
try {
await directAcp.prompt(
sessionId,
content,
Object.keys(meta).length > 0 ? meta : undefined,
);
await directAcp.prompt(sessionId, content);
const tDone = performance.now();
perfLog(
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
@@ -159,7 +150,6 @@ export async function acpCreateSession(
workingDir,
providerId,
options.projectId,
options.personaId,
);
const sessionId = response.sessionId;
await directAcp.setProvider(sessionId, providerId);
+2 -7
View File
@@ -26,7 +26,6 @@ export interface AcpSessionInfo {
projectId?: string | null;
providerId: string | null;
modelId: string | null;
personaId: string | null;
}
export const DEPRECATED_PROVIDER_IDS = new Set([
@@ -83,7 +82,6 @@ export async function listSessions(): Promise<AcpSessionInfo[]> {
projectId: (info._meta?.projectId as string) ?? null,
providerId: (info._meta?.providerId as string) ?? null,
modelId: (info._meta?.modelId as string) ?? null,
personaId: (info._meta?.personaId as string) ?? null,
}));
}
@@ -118,7 +116,6 @@ export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
projectId: (response._meta?.projectId as string) ?? null,
providerId: (response._meta?.providerId as string) ?? null,
modelId: (response._meta?.modelId as string) ?? null,
personaId: (response._meta?.personaId as string) ?? null,
};
}
@@ -207,7 +204,6 @@ export async function newSession(
workingDir: string,
providerId?: string,
projectId?: string,
personaId?: string,
): Promise<NewSessionResponse> {
const tClient = performance.now();
const client = await getClient();
@@ -216,11 +212,10 @@ export async function newSession(
mcpServers: [],
};
const meta: Record<string, string> = {};
const meta: Record<string, string> = { client: "goose" };
if (providerId) meta.provider = providerId;
if (projectId) meta.projectId = projectId;
if (personaId) meta.personaId = personaId;
if (Object.keys(meta).length > 0) request._meta = meta;
request._meta = meta;
const tCall = performance.now();
const response = await client.newSession(request);
-1
View File
@@ -54,7 +54,6 @@ export interface Session {
title: string;
projectId?: string | null;
providerId?: string;
personaId?: string;
modelId?: string;
modelName?: string;
workingDir?: string | null;
+10 -5
View File
@@ -8,11 +8,16 @@ async function clickNewChatInProject(
name: projectName,
exact: true,
});
await projectButton.hover();
await projectButton
.locator("xpath=..")
.getByTitle("New chat in project")
.click();
// The "New chat" button uses group-hover:visible and is invisible by default.
// Headless Playwright on Linux doesn't reliably trigger CSS :hover,
// so we force the button visible via JS before clicking.
const row = projectButton.locator("xpath=..");
const newChatBtn = row.getByTitle("New chat in project");
await newChatBtn.evaluate((el) => {
el.style.visibility = "visible";
el.style.opacity = "1";
});
await newChatBtn.click();
}
test.describe("Draft persistence", () => {
+27 -1
View File
@@ -124,6 +124,27 @@ export function buildInitScript(options?: {
supportingFiles: [],
});
const projectToSourceEntry = (p) => ({
type: "project",
name: p.id ?? p.name?.toLowerCase(),
description: p.description ?? "",
content: p.prompt ?? "",
path: "/mock/.agents/projects/" + (p.id ?? p.name?.toLowerCase()),
global: true,
supportingFiles: [],
properties: {
title: p.name,
icon: p.icon ?? "",
color: p.color ?? "",
preferredProvider: p.preferredProvider ?? null,
preferredModel: p.preferredModel ?? null,
workingDirs: p.workingDirs ?? [],
useWorktrees: p.useWorktrees ?? false,
order: p.order ?? 0,
archivedAt: null,
},
});
function nowIso() {
return new Date().toISOString();
}
@@ -240,8 +261,13 @@ export function buildInitScript(options?: {
case "_goose/working_dir/update":
case "goose/working_dir/update":
return jsonRpcResult(message.id, {});
case "_goose/sources/list":
case "_goose/sources/list": {
const sourceType = message.params?.type;
if (sourceType === "project") {
return jsonRpcResult(message.id, { sources: PROJECTS.map(projectToSourceEntry) });
}
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
}
case "_goose/sources/create":
return jsonRpcResult(message.id, {
source: {
+6 -2
View File
@@ -52,8 +52,12 @@ test.describe("Skills view", () => {
await page.getByPlaceholder("Search skills").fill("review");
await expect(page.getByText("code-review")).toBeVisible();
await expect(page.getByText("test-writer")).not.toBeVisible();
await expect(
page.getByRole("button", { name: "Open code-review details" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Open test-writer details" }),
).not.toBeVisible();
});
test("project filtering isolates project skills", async ({