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,
|
&self,
|
||||||
req: AddExtensionRequest,
|
req: AddExtensionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> 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| {
|
let config: ExtensionConfig = serde_json::from_value(req.config).map_err(|e| {
|
||||||
agent_client_protocol::Error::invalid_params().data(format!("bad config: {e}"))
|
agent_client_protocol::Error::invalid_params().data(format!("bad config: {e}"))
|
||||||
})?;
|
})?;
|
||||||
let agent = self.get_session_agent(&req.session_id, None).await?;
|
let agent = self.get_session_agent(&req.session_id, None).await?;
|
||||||
agent
|
agent
|
||||||
.add_extension(config, &internal_id)
|
.add_extension(config, session_id)
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
Ok(EmptyResponse {})
|
Ok(EmptyResponse {})
|
||||||
@@ -21,10 +21,10 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: RemoveExtensionRequest,
|
req: RemoveExtensionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> 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?;
|
let agent = self.get_session_agent(&req.session_id, None).await?;
|
||||||
agent
|
agent
|
||||||
.remove_extension(&req.name, &internal_id)
|
.remove_extension(&req.name, session_id)
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
Ok(EmptyResponse {})
|
Ok(EmptyResponse {})
|
||||||
@@ -114,10 +114,10 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: GetSessionExtensionsRequest,
|
req: GetSessionExtensionsRequest,
|
||||||
) -> Result<GetSessionExtensionsResponse, agent_client_protocol::Error> {
|
) -> 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
|
let session = self
|
||||||
.session_manager
|
.session_manager
|
||||||
.get_session(&internal_id, false)
|
.get_session(session_id, false)
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: ReadResourceRequest,
|
req: ReadResourceRequest,
|
||||||
) -> Result<ReadResourceResponse, agent_client_protocol::Error> {
|
) -> 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 agent = self.get_session_agent(&req.session_id, None).await?;
|
||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
let result = agent
|
let result = agent
|
||||||
.extension_manager
|
.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
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
let result_json = serde_json::to_value(&result).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")
|
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
|
self.session_manager
|
||||||
.update(&internal_id)
|
.update(session_id)
|
||||||
.working_dir(path.clone())
|
.working_dir(path.clone())
|
||||||
.apply()
|
.apply()
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
|
|
||||||
self.thread_manager
|
if let Some(session) = self.sessions.lock().await.get_mut(session_id) {
|
||||||
.update_working_dir(&req.session_id, &working_dir)
|
|
||||||
.await
|
|
||||||
.internal_err()?;
|
|
||||||
|
|
||||||
if let Some(session) = self.sessions.lock().await.get_mut(&req.session_id) {
|
|
||||||
match &session.agent {
|
match &session.agent {
|
||||||
AgentHandle::Ready(agent) => {
|
AgentHandle::Ready(agent) => {
|
||||||
agent.extension_manager.update_working_dir(&path).await;
|
agent.extension_manager.update_working_dir(&path).await;
|
||||||
@@ -47,9 +42,8 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: DeleteSessionRequest,
|
req: DeleteSessionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||||
// Delete the thread and all its internal sessions + messages.
|
self.session_manager
|
||||||
self.thread_manager
|
.delete_session(&req.session_id)
|
||||||
.delete_thread(&req.session_id)
|
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
self.sessions.lock().await.remove(&req.session_id);
|
self.sessions.lock().await.remove(&req.session_id);
|
||||||
@@ -60,17 +54,9 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: ExportSessionRequest,
|
req: ExportSessionRequest,
|
||||||
) -> Result<ExportSessionResponse, agent_client_protocol::Error> {
|
) -> 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
|
let data = self
|
||||||
.session_manager
|
.session_manager
|
||||||
.export_session(&internal_id)
|
.export_session(&req.session_id)
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
Ok(ExportSessionResponse { data })
|
Ok(ExportSessionResponse { data })
|
||||||
@@ -82,51 +68,17 @@ impl GooseAcpAgent {
|
|||||||
) -> Result<ImportSessionResponse, agent_client_protocol::Error> {
|
) -> Result<ImportSessionResponse, agent_client_protocol::Error> {
|
||||||
let session = self
|
let session = self
|
||||||
.session_manager
|
.session_manager
|
||||||
.import_session(&req.data, Some(SessionType::Acp))
|
.import_session(&req.data, None)
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
|
|
||||||
// Create a thread for the imported session.
|
let msg_count = session.message_count as u64;
|
||||||
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()?;
|
|
||||||
|
|
||||||
Ok(ImportSessionResponse {
|
Ok(ImportSessionResponse {
|
||||||
session_id: thread.id,
|
session_id: session.id,
|
||||||
title: Some(thread.name),
|
title: Some(session.name),
|
||||||
updated_at: Some(thread.updated_at.to_rfc3339()),
|
updated_at: Some(session.updated_at.to_rfc3339()),
|
||||||
message_count: thread.message_count as u64,
|
message_count: msg_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +86,12 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: UpdateSessionProjectRequest,
|
req: UpdateSessionProjectRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||||
let project_id = req.project_id;
|
self.session_manager
|
||||||
self.update_thread_metadata(&req.session_id, move |meta| {
|
.update(&req.session_id)
|
||||||
meta.project_id = project_id;
|
.project_id(req.project_id)
|
||||||
})
|
.apply()
|
||||||
.await?;
|
.await
|
||||||
|
.internal_err()?;
|
||||||
Ok(EmptyResponse {})
|
Ok(EmptyResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,20 +99,12 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: RenameSessionRequest,
|
req: RenameSessionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||||
let title = req.title;
|
self.session_manager
|
||||||
let thread = self
|
.update(&req.session_id)
|
||||||
.thread_manager
|
.user_provided_name(req.title)
|
||||||
.update_thread(&req.session_id, Some(title.clone()), Some(true), None)
|
.apply()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
|
.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 {})
|
Ok(EmptyResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +112,10 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: ArchiveSessionRequest,
|
req: ArchiveSessionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||||
self.thread_manager
|
self.session_manager
|
||||||
.archive_thread(&req.session_id)
|
.update(&req.session_id)
|
||||||
|
.archived_at(Some(chrono::Utc::now()))
|
||||||
|
.apply()
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
self.sessions.lock().await.remove(&req.session_id);
|
self.sessions.lock().await.remove(&req.session_id);
|
||||||
@@ -179,8 +126,10 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: UnarchiveSessionRequest,
|
req: UnarchiveSessionRequest,
|
||||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||||
self.thread_manager
|
self.session_manager
|
||||||
.unarchive_thread(&req.session_id)
|
.update(&req.session_id)
|
||||||
|
.archived_at(None)
|
||||||
|
.apply()
|
||||||
.await
|
.await
|
||||||
.internal_err()?;
|
.internal_err()?;
|
||||||
Ok(EmptyResponse {})
|
Ok(EmptyResponse {})
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: GetToolsRequest,
|
req: GetToolsRequest,
|
||||||
) -> Result<GetToolsResponse, agent_client_protocol::Error> {
|
) -> 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 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
|
let tools_json = tools
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| serde_json::to_value(&t))
|
.map(|t| serde_json::to_value(&t))
|
||||||
@@ -22,9 +22,9 @@ impl GooseAcpAgent {
|
|||||||
&self,
|
&self,
|
||||||
req: GooseToolCallRequest,
|
req: GooseToolCallRequest,
|
||||||
) -> Result<GooseToolCallResponse, agent_client_protocol::Error> {
|
) -> 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 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 {
|
let Some(tool) = tools.iter().find(|t| *t.name == req.name) else {
|
||||||
return Err(agent_client_protocol::Error::invalid_params().data("tool not found"));
|
return Err(agent_client_protocol::Error::invalid_params().data("tool not found"));
|
||||||
@@ -52,7 +52,7 @@ impl GooseAcpAgent {
|
|||||||
params
|
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
|
let tool_result = agent
|
||||||
.extension_manager
|
.extension_manager
|
||||||
.dispatch_tool_call(&ctx, tool_call, CancellationToken::new())
|
.dispatch_tool_call(&ctx, tool_call, CancellationToken::new())
|
||||||
|
|||||||
@@ -367,11 +367,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
|
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
|
||||||
let thread_id = session.thread_id.as_deref()?;
|
let project_id = session.project_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 entry = crate::sources::read_project(project_id).ok()?;
|
let entry = crate::sources::read_project(project_id).ok()?;
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
parts.push(format!("# Project: {}", entry.name));
|
parts.push(format!("# Project: {}", entry.name));
|
||||||
|
|||||||
@@ -382,28 +382,6 @@ mod tests {
|
|||||||
assert_eq!(manager.session_count().await, 1);
|
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]
|
#[tokio::test]
|
||||||
async fn test_eviction_updates_last_used() {
|
async fn test_eviction_updates_last_used() {
|
||||||
// Test that accessing a session updates its last_used timestamp
|
// Test that accessing a session updates its last_used timestamp
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ mod diagnostics;
|
|||||||
pub mod extension_data;
|
pub mod extension_data;
|
||||||
mod legacy;
|
mod legacy;
|
||||||
pub mod session_manager;
|
pub mod session_manager;
|
||||||
pub mod thread_manager;
|
|
||||||
|
|
||||||
pub use diagnostics::{
|
pub use diagnostics::{
|
||||||
config_path, generate_diagnostics, get_system_info, latest_llm_log_path,
|
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::{
|
pub use session_manager::{
|
||||||
Session, SessionInsights, SessionManager, SessionNameUpdate, SessionType, SessionUpdateBuilder,
|
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 tracing::{info, warn};
|
||||||
use utoipa::ToSchema;
|
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 SESSIONS_FOLDER: &str = "sessions";
|
||||||
pub const DB_NAME: &str = "sessions.db";
|
pub const DB_NAME: &str = "sessions.db";
|
||||||
|
|
||||||
@@ -82,7 +82,9 @@ pub struct Session {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub goose_mode: GooseMode,
|
pub goose_mode: GooseMode,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub thread_id: Option<String>,
|
pub archived_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SessionUpdateBuilder<'a> {
|
pub struct SessionUpdateBuilder<'a> {
|
||||||
@@ -105,7 +107,9 @@ pub struct SessionUpdateBuilder<'a> {
|
|||||||
provider_name: Option<Option<String>>,
|
provider_name: Option<Option<String>>,
|
||||||
model_config: Option<Option<ModelConfig>>,
|
model_config: Option<Option<ModelConfig>>,
|
||||||
goose_mode: Option<GooseMode>,
|
goose_mode: Option<GooseMode>,
|
||||||
thread_id: Option<Option<String>>,
|
archived_at: Option<Option<DateTime<Utc>>>,
|
||||||
|
|
||||||
|
project_id: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug)]
|
||||||
@@ -137,7 +141,8 @@ impl<'a> SessionUpdateBuilder<'a> {
|
|||||||
provider_name: None,
|
provider_name: None,
|
||||||
model_config: None,
|
model_config: None,
|
||||||
goose_mode: None,
|
goose_mode: None,
|
||||||
thread_id: None,
|
archived_at: None,
|
||||||
|
project_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +251,13 @@ impl<'a> SessionUpdateBuilder<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn thread_id(mut self, thread_id: Option<String>) -> Self {
|
pub fn archived_at(mut self, archived_at: Option<DateTime<Utc>>) -> Self {
|
||||||
self.thread_id = Some(thread_id);
|
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
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +268,11 @@ pub struct SessionManager {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionNameUpdate {
|
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 {
|
impl SessionManager {
|
||||||
@@ -384,21 +398,16 @@ impl SessionManager {
|
|||||||
.apply()
|
.apply()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Also update the thread name so ACP clients see it via session/list.
|
let session = self.get_session(id, false).await?;
|
||||||
if let Some(ref thread_id) = session.thread_id {
|
return Ok(Some(SessionNameUpdate {
|
||||||
let thread_mgr = super::thread_manager::ThreadManager::new(self.storage.clone());
|
session_id: id.to_string(),
|
||||||
let thread = thread_mgr.get_thread(thread_id).await?;
|
name,
|
||||||
if !thread.user_set_name && thread.name != name {
|
updated_at: session.updated_at,
|
||||||
let thread = thread_mgr
|
message_count: session.message_count,
|
||||||
.update_thread(thread_id, Some(name), Some(false), None)
|
user_set_name: session.user_set_name,
|
||||||
.await?;
|
}));
|
||||||
return Ok(Some(SessionNameUpdate { thread }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_chat_history(
|
pub async fn search_chat_history(
|
||||||
@@ -433,6 +442,22 @@ impl SessionManager {
|
|||||||
.update_message_metadata(id, message_id, f)
|
.update_message_metadata(id, message_id, f)
|
||||||
.await
|
.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 {
|
pub struct SessionStorage {
|
||||||
@@ -473,7 +498,8 @@ impl Default for Session {
|
|||||||
provider_name: None,
|
provider_name: None,
|
||||||
model_config: None,
|
model_config: None,
|
||||||
goose_mode: GooseMode::default(),
|
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()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or_default(),
|
.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,
|
provider_name TEXT,
|
||||||
model_config_json TEXT,
|
model_config_json TEXT,
|
||||||
goose_mode TEXT NOT NULL DEFAULT 'auto',
|
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)")
|
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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?;
|
crate::providers::inventory::create_tables(pool).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1075,6 +1060,46 @@ impl SessionStorage {
|
|||||||
11 => {
|
11 => {
|
||||||
crate::providers::inventory::create_tables_in_tx(tx).await?;
|
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);
|
anyhow::bail!("Unknown migration version: {}", version);
|
||||||
}
|
}
|
||||||
@@ -1136,7 +1161,8 @@ impl SessionStorage {
|
|||||||
total_tokens, input_tokens, output_tokens,
|
total_tokens, input_tokens, output_tokens,
|
||||||
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
||||||
schedule_id, recipe_json, user_recipe_values_json,
|
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
|
FROM sessions
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
"#,
|
"#,
|
||||||
@@ -1200,7 +1226,9 @@ impl SessionStorage {
|
|||||||
add_update!(builder.provider_name, "provider_name");
|
add_update!(builder.provider_name, "provider_name");
|
||||||
add_update!(builder.model_config, "model_config_json");
|
add_update!(builder.model_config, "model_config_json");
|
||||||
add_update!(builder.goose_mode, "goose_mode");
|
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() {
|
if updates.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1269,14 +1297,22 @@ impl SessionStorage {
|
|||||||
if let Some(goose_mode) = builder.goose_mode {
|
if let Some(goose_mode) = builder.goose_mode {
|
||||||
q = q.bind(goose_mode.to_string());
|
q = q.bind(goose_mode.to_string());
|
||||||
}
|
}
|
||||||
if let Some(thread_id) = builder.thread_id {
|
if let Some(ref archived_at) = builder.archived_at {
|
||||||
q = q.bind(thread_id);
|
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 pool = self.pool().await?;
|
||||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||||
q = q.bind(&builder.session_id);
|
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?;
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1423,7 +1459,8 @@ impl SessionStorage {
|
|||||||
s.total_tokens, s.input_tokens, s.output_tokens,
|
s.total_tokens, s.input_tokens, s.output_tokens,
|
||||||
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_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.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
|
COUNT(m.id) as message_count
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
LEFT JOIN messages m ON s.id = m.session_id
|
LEFT JOIN messages m ON s.id = m.session_id
|
||||||
@@ -1582,15 +1619,15 @@ impl SessionStorage {
|
|||||||
.recipe(original_session.recipe)
|
.recipe(original_session.recipe)
|
||||||
.user_recipe_values(original_session.user_recipe_values);
|
.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 {
|
if let Some(provider_name) = original_session.provider_name {
|
||||||
builder = builder.provider_name(provider_name);
|
builder = builder.provider_name(provider_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(model_config) = original_session.model_config {
|
if let Some(model_config) = original_session.model_config {
|
||||||
builder = builder.model_config(model_config);
|
builder = builder.model_config(model_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder = builder.goose_mode(original_session.goose_mode);
|
builder = builder.goose_mode(original_session.goose_mode);
|
||||||
|
|
||||||
builder.apply().await?;
|
builder.apply().await?;
|
||||||
@@ -1680,6 +1717,81 @@ impl SessionStorage {
|
|||||||
|
|
||||||
Ok(())
|
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)]
|
#[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 {
|
if let Some(ref mut meta) = s.meta {
|
||||||
assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some());
|
assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some());
|
||||||
meta.remove("createdAt");
|
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();
|
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",
|
"format": "int32",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"archived_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -7766,6 +7771,10 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"project_id": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"provider_name": {
|
"provider_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@@ -7785,10 +7794,6 @@
|
|||||||
"session_type": {
|
"session_type": {
|
||||||
"$ref": "#/components/schemas/SessionType"
|
"$ref": "#/components/schemas/SessionType"
|
||||||
},
|
},
|
||||||
"thread_id": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"total_tokens": {
|
"total_tokens": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
|
|||||||
@@ -1256,6 +1256,7 @@ export type Session = {
|
|||||||
accumulated_input_tokens?: number | null;
|
accumulated_input_tokens?: number | null;
|
||||||
accumulated_output_tokens?: number | null;
|
accumulated_output_tokens?: number | null;
|
||||||
accumulated_total_tokens?: number | null;
|
accumulated_total_tokens?: number | null;
|
||||||
|
archived_at?: string | null;
|
||||||
conversation?: Conversation | null;
|
conversation?: Conversation | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
extension_data: ExtensionData;
|
extension_data: ExtensionData;
|
||||||
@@ -1266,11 +1267,11 @@ export type Session = {
|
|||||||
model_config?: ModelConfig | null;
|
model_config?: ModelConfig | null;
|
||||||
name: string;
|
name: string;
|
||||||
output_tokens?: number | null;
|
output_tokens?: number | null;
|
||||||
|
project_id?: string | null;
|
||||||
provider_name?: string | null;
|
provider_name?: string | null;
|
||||||
recipe?: Recipe | null;
|
recipe?: Recipe | null;
|
||||||
schedule_id?: string | null;
|
schedule_id?: string | null;
|
||||||
session_type?: SessionType;
|
session_type?: SessionType;
|
||||||
thread_id?: string | null;
|
|
||||||
total_tokens?: number | null;
|
total_tokens?: number | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
user_recipe_values?: {
|
user_recipe_values?: {
|
||||||
|
|||||||
@@ -80,11 +80,7 @@ describe("useChat compaction", () => {
|
|||||||
await result.current.compactConversation();
|
await result.current.compactConversation();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
|
||||||
"session-1",
|
|
||||||
"/compact",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
|
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
|
||||||
|
|
||||||
const messages = useChatStore.getState().messagesBySession["session-1"];
|
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 () => {
|
it("blocks new sends while compaction is in flight", async () => {
|
||||||
const compactDeferred = createDeferredPromise();
|
const compactDeferred = createDeferredPromise();
|
||||||
mockAcpSendMessage.mockImplementation(
|
mockAcpSendMessage.mockImplementation(
|
||||||
@@ -174,11 +141,7 @@ describe("useChat compaction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
|
||||||
"session-1",
|
|
||||||
"/compact",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
useChatStore.getState().messagesBySession["session-1"],
|
useChatStore.getState().messagesBySession["session-1"],
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
@@ -214,11 +177,7 @@ describe("useChat compaction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact");
|
||||||
"session-1",
|
|
||||||
"/compact",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(mockAcpLoadSession).not.toHaveBeenCalled();
|
expect(mockAcpLoadSession).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
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
|
...(sendOptions?.assistantPrompt
|
||||||
? { assistantPrompt: sendOptions.assistantPrompt }
|
? { assistantPrompt: sendOptions.assistantPrompt }
|
||||||
: {}),
|
: {}),
|
||||||
personaId: effectivePersonaInfo?.id,
|
|
||||||
personaName: effectivePersonaInfo?.name,
|
|
||||||
images: images?.map(
|
images: images?.map(
|
||||||
(img) => [img.base64, img.mimeType] as [string, string],
|
(img) => [img.base64, img.mimeType] as [string, string],
|
||||||
),
|
),
|
||||||
@@ -415,10 +414,7 @@ export function useChat(
|
|||||||
clearReplayBuffer(sessionId);
|
clearReplayBuffer(sessionId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sendOptions = effectivePersonaInfo?.id
|
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER);
|
||||||
? { personaId: effectivePersonaInfo.id }
|
|
||||||
: undefined;
|
|
||||||
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
|
|
||||||
|
|
||||||
// Command responses are streamed via prompt notifications, but the ACP
|
// Command responses are streamed via prompt notifications, but the ACP
|
||||||
// layer does not currently forward history replacement events. Drop those
|
// layer does not currently forward history replacement events. Drop those
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function useChatSessionController({
|
|||||||
const selectedPersonaId =
|
const selectedPersonaId =
|
||||||
pendingPersonaId !== undefined
|
pendingPersonaId !== undefined
|
||||||
? pendingPersonaId
|
? pendingPersonaId
|
||||||
: (session?.personaId ?? null);
|
: (session?.agentId ?? null);
|
||||||
const selectedPersona = personas.find(
|
const selectedPersona = personas.find(
|
||||||
(persona) => persona.id === selectedPersonaId,
|
(persona) => persona.id === selectedPersonaId,
|
||||||
);
|
);
|
||||||
@@ -378,7 +378,7 @@ export function useChatSessionController({
|
|||||||
}
|
}
|
||||||
useChatSessionStore
|
useChatSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.patchSession(sessionId, { personaId: personaId ?? undefined });
|
.patchSession(sessionId, { agentId: personaId ?? undefined });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
handleProviderChange,
|
handleProviderChange,
|
||||||
@@ -399,7 +399,7 @@ export function useChatSessionController({
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
useChatSessionStore
|
useChatSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.patchSession(sessionId, { personaId: undefined });
|
.patchSession(sessionId, { agentId: undefined });
|
||||||
} else {
|
} else {
|
||||||
setPendingPersonaId(undefined);
|
setPendingPersonaId(undefined);
|
||||||
}
|
}
|
||||||
@@ -595,7 +595,7 @@ export function useChatSessionController({
|
|||||||
const nextPersonaId =
|
const nextPersonaId =
|
||||||
pendingPersonaId !== undefined
|
pendingPersonaId !== undefined
|
||||||
? (pendingPersonaId ?? undefined)
|
? (pendingPersonaId ?? undefined)
|
||||||
: session?.personaId;
|
: session?.agentId;
|
||||||
const nextProjectId =
|
const nextProjectId =
|
||||||
pendingProjectId !== undefined
|
pendingProjectId !== undefined
|
||||||
? pendingProjectId
|
? pendingProjectId
|
||||||
@@ -610,10 +610,10 @@ export function useChatSessionController({
|
|||||||
|
|
||||||
const patch: {
|
const patch: {
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
personaId?: string | undefined;
|
agentId?: string | undefined;
|
||||||
|
projectId?: string | null;
|
||||||
modelId?: string | undefined;
|
modelId?: string | undefined;
|
||||||
modelName?: string | undefined;
|
modelName?: string | undefined;
|
||||||
projectId?: string | null;
|
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (hasPendingProvider) {
|
if (hasPendingProvider) {
|
||||||
@@ -622,7 +622,7 @@ export function useChatSessionController({
|
|||||||
patch.modelName = undefined;
|
patch.modelName = undefined;
|
||||||
}
|
}
|
||||||
if (hasPendingPersona) {
|
if (hasPendingPersona) {
|
||||||
patch.personaId = nextPersonaId;
|
patch.agentId = nextPersonaId;
|
||||||
}
|
}
|
||||||
if (hasPendingProject) {
|
if (hasPendingProject) {
|
||||||
patch.projectId = nextProjectId ?? null;
|
patch.projectId = nextProjectId ?? null;
|
||||||
@@ -690,7 +690,7 @@ export function useChatSessionController({
|
|||||||
pendingQueuedMessage,
|
pendingQueuedMessage,
|
||||||
prepareCurrentSession,
|
prepareCurrentSession,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
session?.personaId,
|
session?.agentId,
|
||||||
session?.projectId,
|
session?.projectId,
|
||||||
sessionId,
|
sessionId,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("chatSessionStore", () => {
|
|||||||
title: "New Chat",
|
title: "New Chat",
|
||||||
providerId: "openai",
|
providerId: "openai",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
personaId: "persona-1",
|
agentId: "persona-1",
|
||||||
modelId: "gpt-4.1",
|
modelId: "gpt-4.1",
|
||||||
modelName: "GPT-4.1",
|
modelName: "GPT-4.1",
|
||||||
workingDir: "/tmp/project",
|
workingDir: "/tmp/project",
|
||||||
@@ -72,7 +72,6 @@ describe("chatSessionStore", () => {
|
|||||||
"/tmp/project",
|
"/tmp/project",
|
||||||
{
|
{
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
personaId: "persona-1",
|
|
||||||
modelId: "gpt-4.1",
|
modelId: "gpt-4.1",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -81,7 +80,7 @@ describe("chatSessionStore", () => {
|
|||||||
title: "New Chat",
|
title: "New Chat",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
providerId: "openai",
|
providerId: "openai",
|
||||||
personaId: "persona-1",
|
agentId: "persona-1",
|
||||||
modelId: "gpt-4.1",
|
modelId: "gpt-4.1",
|
||||||
modelName: "GPT-4.1",
|
modelName: "GPT-4.1",
|
||||||
workingDir: "/tmp/project",
|
workingDir: "/tmp/project",
|
||||||
@@ -146,7 +145,6 @@ describe("chatSessionStore", () => {
|
|||||||
workingDir: "/tmp/project-123",
|
workingDir: "/tmp/project-123",
|
||||||
projectId: "project-123",
|
projectId: "project-123",
|
||||||
providerId: "anthropic",
|
providerId: "anthropic",
|
||||||
personaId: "persona-1",
|
|
||||||
modelId: "claude-sonnet-4",
|
modelId: "claude-sonnet-4",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -157,7 +155,6 @@ describe("chatSessionStore", () => {
|
|||||||
expect(session.title).toBe("Renamed Chat");
|
expect(session.title).toBe("Renamed Chat");
|
||||||
expect(session.projectId).toBe("project-123");
|
expect(session.projectId).toBe("project-123");
|
||||||
expect(session.providerId).toBe("anthropic");
|
expect(session.providerId).toBe("anthropic");
|
||||||
expect(session.personaId).toBe("persona-1");
|
|
||||||
expect(session.createdAt).toBe("2026-03-31");
|
expect(session.createdAt).toBe("2026-03-31");
|
||||||
expect(session.updatedAt).toBe("2026-04-02");
|
expect(session.updatedAt).toBe("2026-04-02");
|
||||||
expect(session.messageCount).toBe(7);
|
expect(session.messageCount).toBe(7);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface ChatSession {
|
|||||||
title: string;
|
title: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
personaId?: string;
|
agentId?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
workingDir?: string | null;
|
workingDir?: string | null;
|
||||||
@@ -66,7 +66,7 @@ interface CreateSessionOpts {
|
|||||||
title?: string;
|
title?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
personaId?: string;
|
agentId?: string;
|
||||||
workingDir?: string;
|
workingDir?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
@@ -100,7 +100,6 @@ function acpSessionToChatSession(session: AcpSessionInfo): ChatSession {
|
|||||||
title: normalizeAcpTitle(session.title) ?? "Untitled",
|
title: normalizeAcpTitle(session.title) ?? "Untitled",
|
||||||
projectId: session.projectId ?? undefined,
|
projectId: session.projectId ?? undefined,
|
||||||
providerId: session.providerId ?? undefined,
|
providerId: session.providerId ?? undefined,
|
||||||
personaId: session.personaId ?? undefined,
|
|
||||||
modelId: session.modelId ?? undefined,
|
modelId: session.modelId ?? undefined,
|
||||||
workingDir: session.workingDir ?? undefined,
|
workingDir: session.workingDir ?? undefined,
|
||||||
createdAt: session.createdAt ?? session.updatedAt ?? now,
|
createdAt: session.createdAt ?? session.updatedAt ?? now,
|
||||||
@@ -123,7 +122,6 @@ export function sessionToChatSession(session: Session): ChatSession {
|
|||||||
title: session.title,
|
title: session.title,
|
||||||
projectId: session.projectId,
|
projectId: session.projectId,
|
||||||
providerId: session.providerId,
|
providerId: session.providerId,
|
||||||
personaId: session.personaId,
|
|
||||||
modelId: session.modelId,
|
modelId: session.modelId,
|
||||||
modelName: session.modelName,
|
modelName: session.modelName,
|
||||||
workingDir: session.workingDir,
|
workingDir: session.workingDir,
|
||||||
@@ -150,7 +148,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const providerId = opts.providerId ?? "goose";
|
const providerId = opts.providerId ?? "goose";
|
||||||
const { sessionId } = await acpCreateSession(providerId, opts.workingDir, {
|
const { sessionId } = await acpCreateSession(providerId, opts.workingDir, {
|
||||||
personaId: opts.personaId,
|
|
||||||
modelId: opts.modelId,
|
modelId: opts.modelId,
|
||||||
projectId: opts.projectId,
|
projectId: opts.projectId,
|
||||||
});
|
});
|
||||||
@@ -159,7 +156,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
title: opts.title ?? DEFAULT_CHAT_TITLE,
|
title: opts.title ?? DEFAULT_CHAT_TITLE,
|
||||||
projectId: opts.projectId,
|
projectId: opts.projectId,
|
||||||
providerId,
|
providerId,
|
||||||
personaId: opts.personaId,
|
agentId: opts.agentId,
|
||||||
modelId: opts.modelId,
|
modelId: opts.modelId,
|
||||||
modelName: opts.modelName,
|
modelName: opts.modelName,
|
||||||
workingDir: opts.workingDir,
|
workingDir: opts.workingDir,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("buildSessionSearchResults", () => {
|
|||||||
makeSession({
|
makeSession({
|
||||||
id: "session-2",
|
id: "session-2",
|
||||||
title: "Builder notes",
|
title: "Builder notes",
|
||||||
personaId: "persona-1",
|
agentId: "persona-1",
|
||||||
updatedAt: "2026-04-09T12:00:00Z",
|
updatedAt: "2026-04-09T12:00:00Z",
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function makeSession(
|
|||||||
|
|
||||||
describe("filterSessions", () => {
|
describe("filterSessions", () => {
|
||||||
const sessions: ChatSession[] = [
|
const sessions: ChatSession[] = [
|
||||||
makeSession({ id: "1", title: "Fix sidebar bug", personaId: "p1" }),
|
makeSession({ id: "1", title: "Fix sidebar bug", agentId: "p1" }),
|
||||||
makeSession({
|
makeSession({
|
||||||
id: "2",
|
id: "2",
|
||||||
title: "Add pagination",
|
title: "Add pagination",
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ function buildSearchableString(
|
|||||||
parts.push(session.title);
|
parts.push(session.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.personaId) {
|
if (session.agentId) {
|
||||||
const name = resolvers.getPersonaName(session.personaId);
|
const name = resolvers.getPersonaName(session.agentId);
|
||||||
if (name) parts.push(name);
|
if (name) parts.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ export function SessionHistoryView({
|
|||||||
title={result.session.title}
|
title={result.session.title}
|
||||||
updatedAt={result.session.updatedAt}
|
updatedAt={result.session.updatedAt}
|
||||||
personaName={
|
personaName={
|
||||||
result.session.personaId
|
result.session.agentId
|
||||||
? getPersonaName(result.session.personaId)
|
? getPersonaName(result.session.agentId)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
projectName={
|
projectName={
|
||||||
@@ -299,8 +299,8 @@ export function SessionHistoryView({
|
|||||||
title={session.title}
|
title={session.title}
|
||||||
updatedAt={session.updatedAt}
|
updatedAt={session.updatedAt}
|
||||||
personaName={
|
personaName={
|
||||||
session.personaId
|
session.agentId
|
||||||
? getPersonaName(session.personaId)
|
? getPersonaName(session.agentId)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
projectName={
|
projectName={
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export function SidebarSearchResults({
|
|||||||
session.title,
|
session.title,
|
||||||
t("common:session.defaultTitle"),
|
t("common:session.defaultTitle"),
|
||||||
);
|
);
|
||||||
const personaName = session.personaId
|
const personaName = session.agentId
|
||||||
? getPersonaName(session.personaId)
|
? getPersonaName(session.agentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const projectName = session.projectId
|
const projectName = session.projectId
|
||||||
? getProjectName(session.projectId)
|
? getProjectName(session.projectId)
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ describe("acpCreateSession", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
acpCreateSession("openai", "/tmp/project", {
|
acpCreateSession("openai", "/tmp/project", {
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
personaId: "persona-1",
|
|
||||||
modelId: "gpt-4.1",
|
modelId: "gpt-4.1",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ sessionId: "acp-session-1" });
|
).resolves.toEqual({ sessionId: "acp-session-1" });
|
||||||
@@ -78,7 +77,6 @@ describe("acpCreateSession", () => {
|
|||||||
"/tmp/project",
|
"/tmp/project",
|
||||||
"openai",
|
"openai",
|
||||||
"project-1",
|
"project-1",
|
||||||
"persona-1",
|
|
||||||
);
|
);
|
||||||
expect(mockLoadSession).not.toHaveBeenCalled();
|
expect(mockLoadSession).not.toHaveBeenCalled();
|
||||||
expect(mockSetProvider).toHaveBeenCalledWith("acp-session-1", "openai");
|
expect(mockSetProvider).toHaveBeenCalledWith("acp-session-1", "openai");
|
||||||
|
|||||||
@@ -21,14 +21,11 @@ export interface AcpProvider {
|
|||||||
export interface AcpSendMessageOptions {
|
export interface AcpSendMessageOptions {
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
assistantPrompt?: string;
|
assistantPrompt?: string;
|
||||||
personaId?: string;
|
|
||||||
personaName?: string;
|
|
||||||
/** Image attachments as [base64Data, mimeType] pairs. */
|
/** Image attachments as [base64Data, mimeType] pairs. */
|
||||||
images?: [string, string][];
|
images?: [string, string][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcpCreateSessionOptions {
|
export interface AcpCreateSessionOptions {
|
||||||
personaId?: string;
|
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
}
|
}
|
||||||
@@ -79,7 +76,7 @@ export async function acpSendMessage(
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
options: AcpSendMessageOptions = {},
|
options: AcpSendMessageOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { systemPrompt, assistantPrompt, personaId, images } = options;
|
const { systemPrompt, assistantPrompt, images } = options;
|
||||||
const sid = sessionId.slice(0, 8);
|
const sid = sessionId.slice(0, 8);
|
||||||
const tStart = performance.now();
|
const tStart = performance.now();
|
||||||
|
|
||||||
@@ -116,14 +113,8 @@ export async function acpSendMessage(
|
|||||||
`[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`,
|
`[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`,
|
||||||
);
|
);
|
||||||
const tPrompt = performance.now();
|
const tPrompt = performance.now();
|
||||||
const meta: Record<string, unknown> = {};
|
|
||||||
if (personaId) meta.personaId = personaId;
|
|
||||||
try {
|
try {
|
||||||
await directAcp.prompt(
|
await directAcp.prompt(sessionId, content);
|
||||||
sessionId,
|
|
||||||
content,
|
|
||||||
Object.keys(meta).length > 0 ? meta : undefined,
|
|
||||||
);
|
|
||||||
const tDone = performance.now();
|
const tDone = performance.now();
|
||||||
perfLog(
|
perfLog(
|
||||||
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
|
`[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,
|
workingDir,
|
||||||
providerId,
|
providerId,
|
||||||
options.projectId,
|
options.projectId,
|
||||||
options.personaId,
|
|
||||||
);
|
);
|
||||||
const sessionId = response.sessionId;
|
const sessionId = response.sessionId;
|
||||||
await directAcp.setProvider(sessionId, providerId);
|
await directAcp.setProvider(sessionId, providerId);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export interface AcpSessionInfo {
|
|||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
providerId: string | null;
|
providerId: string | null;
|
||||||
modelId: string | null;
|
modelId: string | null;
|
||||||
personaId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEPRECATED_PROVIDER_IDS = new Set([
|
export const DEPRECATED_PROVIDER_IDS = new Set([
|
||||||
@@ -83,7 +82,6 @@ export async function listSessions(): Promise<AcpSessionInfo[]> {
|
|||||||
projectId: (info._meta?.projectId as string) ?? null,
|
projectId: (info._meta?.projectId as string) ?? null,
|
||||||
providerId: (info._meta?.providerId as string) ?? null,
|
providerId: (info._meta?.providerId as string) ?? null,
|
||||||
modelId: (info._meta?.modelId 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,
|
projectId: (response._meta?.projectId as string) ?? null,
|
||||||
providerId: (response._meta?.providerId as string) ?? null,
|
providerId: (response._meta?.providerId as string) ?? null,
|
||||||
modelId: (response._meta?.modelId 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,
|
workingDir: string,
|
||||||
providerId?: string,
|
providerId?: string,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
personaId?: string,
|
|
||||||
): Promise<NewSessionResponse> {
|
): Promise<NewSessionResponse> {
|
||||||
const tClient = performance.now();
|
const tClient = performance.now();
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
@@ -216,11 +212,10 @@ export async function newSession(
|
|||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta: Record<string, string> = {};
|
const meta: Record<string, string> = { client: "goose" };
|
||||||
if (providerId) meta.provider = providerId;
|
if (providerId) meta.provider = providerId;
|
||||||
if (projectId) meta.projectId = projectId;
|
if (projectId) meta.projectId = projectId;
|
||||||
if (personaId) meta.personaId = personaId;
|
request._meta = meta;
|
||||||
if (Object.keys(meta).length > 0) request._meta = meta;
|
|
||||||
|
|
||||||
const tCall = performance.now();
|
const tCall = performance.now();
|
||||||
const response = await client.newSession(request);
|
const response = await client.newSession(request);
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export interface Session {
|
|||||||
title: string;
|
title: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
personaId?: string;
|
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
workingDir?: string | null;
|
workingDir?: string | null;
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ async function clickNewChatInProject(
|
|||||||
name: projectName,
|
name: projectName,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
await projectButton.hover();
|
// The "New chat" button uses group-hover:visible and is invisible by default.
|
||||||
await projectButton
|
// Headless Playwright on Linux doesn't reliably trigger CSS :hover,
|
||||||
.locator("xpath=..")
|
// so we force the button visible via JS before clicking.
|
||||||
.getByTitle("New chat in project")
|
const row = projectButton.locator("xpath=..");
|
||||||
.click();
|
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", () => {
|
test.describe("Draft persistence", () => {
|
||||||
|
|||||||
@@ -124,6 +124,27 @@ export function buildInitScript(options?: {
|
|||||||
supportingFiles: [],
|
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() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
@@ -240,8 +261,13 @@ export function buildInitScript(options?: {
|
|||||||
case "_goose/working_dir/update":
|
case "_goose/working_dir/update":
|
||||||
case "goose/working_dir/update":
|
case "goose/working_dir/update":
|
||||||
return jsonRpcResult(message.id, {});
|
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) });
|
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
|
||||||
|
}
|
||||||
case "_goose/sources/create":
|
case "_goose/sources/create":
|
||||||
return jsonRpcResult(message.id, {
|
return jsonRpcResult(message.id, {
|
||||||
source: {
|
source: {
|
||||||
|
|||||||
@@ -52,8 +52,12 @@ test.describe("Skills view", () => {
|
|||||||
|
|
||||||
await page.getByPlaceholder("Search skills").fill("review");
|
await page.getByPlaceholder("Search skills").fill("review");
|
||||||
|
|
||||||
await expect(page.getByText("code-review")).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByText("test-writer")).not.toBeVisible();
|
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 ({
|
test("project filtering isolates project skills", async ({
|
||||||
|
|||||||
Reference in New Issue
Block a user