feat: 添加会话查询命令处理器,支持列出和加载会话功能
This commit is contained in:
parent
b77fc93d71
commit
5eb9a26843
@ -69,119 +69,3 @@ impl InputAdapter for ChannelInputAdapter {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_new_without_title() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/new", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(cmd, Command::CreateSession { title: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_new_with_title() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/new planning session", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(
|
||||
cmd,
|
||||
Command::CreateSession {
|
||||
title: Some(ref t)
|
||||
} if t == "planning session"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_save_without_path() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/save", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(
|
||||
cmd,
|
||||
Command::SaveSession {
|
||||
filepath: None,
|
||||
include_all: false,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_save_with_path() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/save ./session.md", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(
|
||||
cmd,
|
||||
Command::SaveSession {
|
||||
filepath: Some(ref p),
|
||||
include_all: false,
|
||||
} if p == "./session.md"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_save_all() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/save all", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(
|
||||
cmd,
|
||||
Command::SaveSession {
|
||||
filepath: None,
|
||||
include_all: true,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_save_all_with_path() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("/save all ./session.md", ctx).unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
let cmd = result.unwrap();
|
||||
assert!(matches!(
|
||||
cmd,
|
||||
Command::SaveSession {
|
||||
filepath: Some(ref p),
|
||||
include_all: true,
|
||||
} if p == "./session.md"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_adapter_not_command() {
|
||||
let adapter = ChannelInputAdapter::new();
|
||||
let ctx = AdapterContext::new("channel");
|
||||
|
||||
let result = adapter.try_parse("hello world", ctx).unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,18 +12,21 @@ pub struct CommandContext {
|
||||
pub chat_id: Option<String>,
|
||||
/// 发送者ID
|
||||
pub sender_id: String,
|
||||
/// 通道名称(如 "cli", "feishu", "wechat")
|
||||
pub channel_name: String,
|
||||
/// 额外元数据
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CommandContext {
|
||||
/// 创建新的命令上下文
|
||||
pub fn new(sender_id: impl Into<String>) -> Self {
|
||||
pub fn new(sender_id: impl Into<String>, channel_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
request_id: Uuid::new_v4(),
|
||||
session_id: None,
|
||||
chat_id: None,
|
||||
sender_id: sender_id.into(),
|
||||
channel_name: channel_name.into(),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -238,7 +238,7 @@ mod tests {
|
||||
router.register(Box::new(TestHandler));
|
||||
router.register(Box::new(NoOpHandler));
|
||||
|
||||
let ctx = CommandContext::new("test");
|
||||
let ctx = CommandContext::new("test", "test");
|
||||
let cmd = Command::CreateSession { title: None };
|
||||
|
||||
let result = router.dispatch(cmd, ctx).await;
|
||||
@ -252,7 +252,7 @@ mod tests {
|
||||
async fn test_router_no_handler() {
|
||||
let router = CommandRouter::new();
|
||||
|
||||
let ctx = CommandContext::new("test");
|
||||
let ctx = CommandContext::new("test", "test");
|
||||
let cmd = Command::CreateSession { title: None };
|
||||
|
||||
let result = router.dispatch(cmd, ctx).await;
|
||||
|
||||
@ -48,7 +48,7 @@ async fn handle_create_session(
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
let record = handler
|
||||
.cli_sessions
|
||||
.create(title.as_deref())
|
||||
.create_with_channel(&ctx.channel_name, title.as_deref())
|
||||
.map_err(|e| CommandError::new("CREATE_SESSION_ERROR", e.to_string()))?;
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
@ -74,7 +74,7 @@ mod tests {
|
||||
async fn test_create_session_with_title() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionCommandHandler::new(service);
|
||||
let ctx = CommandContext::new("test");
|
||||
let ctx = CommandContext::new("test", "test");
|
||||
let cmd = Command::CreateSession {
|
||||
title: Some("my session".to_string()),
|
||||
};
|
||||
@ -93,7 +93,7 @@ mod tests {
|
||||
async fn test_create_session_without_title() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionCommandHandler::new(service);
|
||||
let ctx = CommandContext::new("test");
|
||||
let ctx = CommandContext::new("test", "test");
|
||||
let cmd = Command::CreateSession { title: None };
|
||||
|
||||
let result = handler.handle(cmd, ctx).await;
|
||||
|
||||
188
src/command/handlers/session_query.rs
Normal file
188
src/command/handlers/session_query.rs
Normal file
@ -0,0 +1,188 @@
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::CommandHandler;
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::gateway::cli_session::CliSessionService;
|
||||
use crate::protocol::SessionSummary;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// 会话查询命令处理器
|
||||
///
|
||||
/// 处理 ListSessions 和 LoadSession 命令
|
||||
pub struct SessionQueryCommandHandler {
|
||||
cli_sessions: CliSessionService,
|
||||
}
|
||||
|
||||
impl SessionQueryCommandHandler {
|
||||
/// 创建新的会话查询命令处理器
|
||||
pub fn new(cli_sessions: CliSessionService) -> Self {
|
||||
Self { cli_sessions }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandHandler for SessionQueryCommandHandler {
|
||||
fn can_handle(&self, cmd: &Command) -> bool {
|
||||
matches!(cmd, Command::ListSessions { .. } | Command::LoadSession { .. })
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
cmd: Command,
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::ListSessions { include_archived } => {
|
||||
handle_list_sessions(self, include_archived, ctx).await
|
||||
}
|
||||
Command::LoadSession { session_id } => {
|
||||
handle_load_session(self, session_id, ctx).await
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理列出会话命令
|
||||
async fn handle_list_sessions(
|
||||
handler: &SessionQueryCommandHandler,
|
||||
include_archived: bool,
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
let records = handler
|
||||
.cli_sessions
|
||||
.list(include_archived)
|
||||
.map_err(|e| CommandError::new("LIST_SESSIONS_ERROR", e.to_string()))?;
|
||||
|
||||
let summaries: Vec<SessionSummary> = records
|
||||
.into_iter()
|
||||
.map(|r| SessionSummary {
|
||||
session_id: r.id,
|
||||
title: r.title,
|
||||
channel_name: r.channel_name,
|
||||
chat_id: r.chat_id,
|
||||
message_count: r.message_count,
|
||||
last_active_at: r.last_active_at,
|
||||
archived_at: r.archived_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 将会话列表序列化为 JSON 存储在 metadata 中
|
||||
let sessions_json =
|
||||
serde_json::to_string(&summaries).map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
let message = if summaries.is_empty() {
|
||||
"No sessions found.".to_string()
|
||||
} else {
|
||||
format!("Found {} session(s)", summaries.len())
|
||||
};
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("sessions", &sessions_json)
|
||||
.with_metadata("count", &summaries.len().to_string()))
|
||||
}
|
||||
|
||||
/// 处理加载会话命令
|
||||
async fn handle_load_session(
|
||||
handler: &SessionQueryCommandHandler,
|
||||
session_id: String,
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
let record = handler
|
||||
.cli_sessions
|
||||
.get(&session_id)
|
||||
.map_err(|e| CommandError::new("LOAD_SESSION_ERROR", e.to_string()))?
|
||||
.ok_or_else(|| CommandError::new("SESSION_NOT_FOUND", format!("Session not found: {}", session_id)))?;
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &record.title)
|
||||
.with_metadata("session_id", &record.id)
|
||||
.with_metadata("title", &record.title)
|
||||
.with_metadata("message_count", &record.message_count.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::SessionStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_test_service() -> CliSessionService {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
CliSessionService::new(store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions_empty() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionQueryCommandHandler::new(service);
|
||||
let ctx = CommandContext::new("test");
|
||||
let cmd = Command::ListSessions {
|
||||
include_archived: false,
|
||||
};
|
||||
|
||||
let result = handler.handle(cmd, ctx).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap();
|
||||
assert!(resp.success);
|
||||
assert!(resp.messages[0].content.contains("No sessions"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions_with_items() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionQueryCommandHandler::new(service.clone());
|
||||
|
||||
// 创建一些会话
|
||||
service.create(Some("test session")).unwrap();
|
||||
|
||||
let ctx = CommandContext::new("test");
|
||||
let cmd = Command::ListSessions {
|
||||
include_archived: false,
|
||||
};
|
||||
|
||||
let result = handler.handle(cmd, ctx).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap();
|
||||
assert!(resp.success);
|
||||
assert!(resp.metadata.contains_key("sessions"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_session_not_found() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionQueryCommandHandler::new(service);
|
||||
let ctx = CommandContext::new("test");
|
||||
let cmd = Command::LoadSession {
|
||||
session_id: "nonexistent".to_string(),
|
||||
};
|
||||
|
||||
let result = handler.handle(cmd, ctx).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_session_success() {
|
||||
let service = create_test_service();
|
||||
let handler = SessionQueryCommandHandler::new(service.clone());
|
||||
|
||||
// 创建会话
|
||||
let record = service.create(Some("test session")).unwrap();
|
||||
|
||||
let ctx = CommandContext::new("test");
|
||||
let cmd = Command::LoadSession {
|
||||
session_id: record.id.clone(),
|
||||
};
|
||||
|
||||
let result = handler.handle(cmd, ctx).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.metadata.get("session_id").unwrap(), &record.id);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,17 @@ impl CliSessionService {
|
||||
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))
|
||||
}
|
||||
|
||||
/// 创建指定通道的会话
|
||||
pub(crate) fn create_with_channel(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
title: Option<&str>,
|
||||
) -> Result<SessionRecord, AgentError> {
|
||||
self.store
|
||||
.create_session(channel_name, title)
|
||||
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, session_id: &str) -> Result<Option<SessionRecord>, AgentError> {
|
||||
self.store
|
||||
.get_session(session_id)
|
||||
|
||||
@ -117,7 +117,7 @@ impl InboundProcessor {
|
||||
|
||||
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
|
||||
// 使用命令路由器处理
|
||||
let cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel)
|
||||
let cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
|
||||
.with_session_id(&inbound.chat_id);
|
||||
|
||||
let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await;
|
||||
|
||||
@ -227,7 +227,7 @@ async fn handle_inbound(
|
||||
)));
|
||||
|
||||
// 构建命令上下文
|
||||
let cmd_ctx = CommandContext::new("websocket")
|
||||
let cmd_ctx = CommandContext::new("websocket", "cli")
|
||||
.with_session_id(current_session_id.as_str());
|
||||
|
||||
// 执行命令
|
||||
|
||||
@ -76,6 +76,18 @@ pub enum WsOutbound {
|
||||
SessionEstablished { session_id: String },
|
||||
#[serde(rename = "session_created")]
|
||||
SessionCreated { session_id: String, title: String },
|
||||
#[serde(rename = "session_list")]
|
||||
SessionList {
|
||||
sessions: Vec<SessionSummary>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_session_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "session_loaded")]
|
||||
SessionLoaded {
|
||||
session_id: String,
|
||||
title: String,
|
||||
message_count: i64,
|
||||
},
|
||||
#[serde(rename = "session_saved")]
|
||||
SessionSaved { session_id: String, filepath: String },
|
||||
#[serde(rename = "pong")]
|
||||
|
||||
@ -204,14 +204,24 @@ impl SessionStore {
|
||||
Self::from_connection(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
pub fn create_cli_session(&self, title: Option<&str>) -> Result<SessionRecord, StorageError> {
|
||||
pub fn create_session(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
title: Option<&str>,
|
||||
) -> Result<SessionRecord, StorageError> {
|
||||
let now = current_timestamp();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let title = title
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("CLI Session {}", &id[..8]));
|
||||
.unwrap_or_else(|| {
|
||||
if channel_name == "cli" {
|
||||
format!("CLI Session {}", &id[..8])
|
||||
} else {
|
||||
format!("Session {}", &id[..8])
|
||||
}
|
||||
});
|
||||
|
||||
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||
conn.execute(
|
||||
@ -220,9 +230,9 @@ impl SessionStore {
|
||||
id, title, channel_name, chat_id, summary,
|
||||
created_at, updated_at, last_active_at, archived_at, deleted_at, message_count,
|
||||
reset_cutoff_seq, user_turn_count, agent_prompt_reinjection_count
|
||||
) VALUES (?1, ?2, 'cli', ?3, NULL, ?4, ?4, ?4, NULL, NULL, 0, 0, 0, 0)
|
||||
) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5, ?5, NULL, NULL, 0, 0, 0, 0)
|
||||
",
|
||||
params![id, title, id, now],
|
||||
params![id, title, channel_name, id, now],
|
||||
)?;
|
||||
|
||||
drop(conn);
|
||||
@ -230,6 +240,10 @@ impl SessionStore {
|
||||
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
|
||||
}
|
||||
|
||||
pub fn create_cli_session(&self, title: Option<&str>) -> Result<SessionRecord, StorageError> {
|
||||
self.create_session("cli", title)
|
||||
}
|
||||
|
||||
pub fn ensure_channel_session(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user