feat: 添加会话查询命令处理器,支持列出和加载会话功能

This commit is contained in:
ooodc 2026-05-14 23:27:23 +08:00
parent b77fc93d71
commit 5eb9a26843
10 changed files with 240 additions and 128 deletions

View File

@ -69,119 +69,3 @@ impl InputAdapter for ChannelInputAdapter {
Ok(None) 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());
}
}

View File

@ -12,18 +12,21 @@ pub struct CommandContext {
pub chat_id: Option<String>, pub chat_id: Option<String>,
/// 发送者ID /// 发送者ID
pub sender_id: String, pub sender_id: String,
/// 通道名称(如 "cli", "feishu", "wechat"
pub channel_name: String,
/// 额外元数据 /// 额外元数据
pub metadata: HashMap<String, String>, pub metadata: HashMap<String, String>,
} }
impl CommandContext { 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 { Self {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
session_id: None, session_id: None,
chat_id: None, chat_id: None,
sender_id: sender_id.into(), sender_id: sender_id.into(),
channel_name: channel_name.into(),
metadata: HashMap::new(), metadata: HashMap::new(),
} }
} }

View File

@ -238,7 +238,7 @@ mod tests {
router.register(Box::new(TestHandler)); router.register(Box::new(TestHandler));
router.register(Box::new(NoOpHandler)); router.register(Box::new(NoOpHandler));
let ctx = CommandContext::new("test"); let ctx = CommandContext::new("test", "test");
let cmd = Command::CreateSession { title: None }; let cmd = Command::CreateSession { title: None };
let result = router.dispatch(cmd, ctx).await; let result = router.dispatch(cmd, ctx).await;
@ -252,7 +252,7 @@ mod tests {
async fn test_router_no_handler() { async fn test_router_no_handler() {
let router = CommandRouter::new(); let router = CommandRouter::new();
let ctx = CommandContext::new("test"); let ctx = CommandContext::new("test", "test");
let cmd = Command::CreateSession { title: None }; let cmd = Command::CreateSession { title: None };
let result = router.dispatch(cmd, ctx).await; let result = router.dispatch(cmd, ctx).await;

View File

@ -48,7 +48,7 @@ async fn handle_create_session(
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
let record = handler let record = handler
.cli_sessions .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()))?; .map_err(|e| CommandError::new("CREATE_SESSION_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
@ -74,7 +74,7 @@ mod tests {
async fn test_create_session_with_title() { async fn test_create_session_with_title() {
let service = create_test_service(); let service = create_test_service();
let handler = SessionCommandHandler::new(service); let handler = SessionCommandHandler::new(service);
let ctx = CommandContext::new("test"); let ctx = CommandContext::new("test", "test");
let cmd = Command::CreateSession { let cmd = Command::CreateSession {
title: Some("my session".to_string()), title: Some("my session".to_string()),
}; };
@ -93,7 +93,7 @@ mod tests {
async fn test_create_session_without_title() { async fn test_create_session_without_title() {
let service = create_test_service(); let service = create_test_service();
let handler = SessionCommandHandler::new(service); let handler = SessionCommandHandler::new(service);
let ctx = CommandContext::new("test"); let ctx = CommandContext::new("test", "test");
let cmd = Command::CreateSession { title: None }; let cmd = Command::CreateSession { title: None };
let result = handler.handle(cmd, ctx).await; let result = handler.handle(cmd, ctx).await;

View 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);
}
}

View File

@ -19,6 +19,17 @@ impl CliSessionService {
.map_err(|err| AgentError::Other(format!("create session error: {}", err))) .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> { pub(crate) fn get(&self, session_id: &str) -> Result<Option<SessionRecord>, AgentError> {
self.store self.store
.get_session(session_id) .get_session(session_id)

View File

@ -117,7 +117,7 @@ impl InboundProcessor {
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) { 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); .with_session_id(&inbound.chat_id);
let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await; let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await;

View File

@ -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()); .with_session_id(current_session_id.as_str());
// 执行命令 // 执行命令

View File

@ -76,6 +76,18 @@ pub enum WsOutbound {
SessionEstablished { session_id: String }, SessionEstablished { session_id: String },
#[serde(rename = "session_created")] #[serde(rename = "session_created")]
SessionCreated { session_id: String, title: String }, 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")] #[serde(rename = "session_saved")]
SessionSaved { session_id: String, filepath: String }, SessionSaved { session_id: String, filepath: String },
#[serde(rename = "pong")] #[serde(rename = "pong")]

View File

@ -204,14 +204,24 @@ impl SessionStore {
Self::from_connection(Connection::open_in_memory()?) 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 now = current_timestamp();
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let title = title let title = title
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned) .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"); let conn = self.conn.lock().expect("session db mutex poisoned");
conn.execute( conn.execute(
@ -220,9 +230,9 @@ impl SessionStore {
id, title, channel_name, chat_id, summary, id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at, archived_at, deleted_at, message_count, created_at, updated_at, last_active_at, archived_at, deleted_at, message_count,
reset_cutoff_seq, user_turn_count, agent_prompt_reinjection_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); drop(conn);
@ -230,6 +240,10 @@ impl SessionStore {
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) .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( pub fn ensure_channel_session(
&self, &self,
channel_name: &str, channel_name: &str,