mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
refactor: remove threads layer, use sessions directly for ACP (#9078)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
+204
-369
File diff suppressed because it is too large
Load Diff
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -54,7 +54,6 @@ export interface Session {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
workingDir?: string | null;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user