refactor: split chat/transport/matrix/bot/messages.rs (912) into mod + on_room_message + handle_message
The 912-line messages.rs is split: - on_room_message.rs: incoming Matrix event dispatch (~600 lines) - handle_message.rs: LLM turn + reply streaming (~265 lines) - mod.rs: format_user_prompt + tests (~70 lines) Tests stay co-located with format_user_prompt in mod.rs. All 2636 tests pass; clippy clean.
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
//! Matrix handle_message — runs the LLM turn for a verified incoming message and
|
||||||
|
//! streams the assistant reply back to the room.
|
||||||
|
|
||||||
|
use crate::chat::util::drain_complete_paragraphs;
|
||||||
|
use crate::http::context::PermissionDecision;
|
||||||
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
|
use crate::slog;
|
||||||
|
use matrix_sdk::ruma::OwnedRoomId;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use super::super::context::BotContext;
|
||||||
|
use super::super::format::markdown_to_html;
|
||||||
|
use super::super::history::{ConversationEntry, ConversationRole, save_history};
|
||||||
|
|
||||||
|
use super::format_user_prompt;
|
||||||
|
|
||||||
|
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||||
|
room_id_str: String,
|
||||||
|
room_id: OwnedRoomId,
|
||||||
|
ctx: BotContext,
|
||||||
|
sender: String,
|
||||||
|
user_message: String,
|
||||||
|
) {
|
||||||
|
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||||
|
// resume the conversation with structured API messages instead of
|
||||||
|
// flattening history into a text prefix.
|
||||||
|
let resume_session_id: Option<String> = {
|
||||||
|
let guard = ctx.history.lock().await;
|
||||||
|
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// The prompt is just the current message with sender attribution.
|
||||||
|
// Prior conversation context is carried by the Claude Code session.
|
||||||
|
let bot_name = &ctx.services.bot_name;
|
||||||
|
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
|
||||||
|
let name = ap.read().await.clone();
|
||||||
|
format!("[Active project: {name}]\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let prompt = format!(
|
||||||
|
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
||||||
|
format_user_prompt(&sender, &user_message)
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider = ClaudeCodeProvider::new();
|
||||||
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
||||||
|
// Keep the sender alive for the duration of the call.
|
||||||
|
let _cancel_tx = cancel_tx;
|
||||||
|
|
||||||
|
// Channel for sending complete paragraphs to the Matrix posting task.
|
||||||
|
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let msg_tx_for_callback = msg_tx.clone();
|
||||||
|
|
||||||
|
// Spawn a task to post messages via the transport as they arrive so we
|
||||||
|
// don't block the LLM stream while waiting for send round-trips.
|
||||||
|
let post_transport = Arc::clone(&ctx.transport);
|
||||||
|
let post_room_id = room_id_str.clone();
|
||||||
|
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
|
let sent_ids_for_post = Arc::clone(&sent_ids);
|
||||||
|
let post_task = tokio::spawn(async move {
|
||||||
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
|
let html = markdown_to_html(&chunk);
|
||||||
|
if let Ok(msg_id) = post_transport
|
||||||
|
.send_message(&post_room_id, &chunk, &html)
|
||||||
|
.await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
sent_ids_for_post.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shared state between the sync token callback and the async outer scope.
|
||||||
|
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
|
||||||
|
let buffer_for_callback = Arc::clone(&buffer);
|
||||||
|
let sent_any_chunk = Arc::new(AtomicBool::new(false));
|
||||||
|
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
|
||||||
|
|
||||||
|
// In gateway mode, run Claude Code in the gateway config directory so it
|
||||||
|
// picks up the `.mcp.json` that points to the gateway's MCP proxy endpoint.
|
||||||
|
// The gateway proxies tool calls to the active project automatically.
|
||||||
|
// In standalone mode, use the project root directly.
|
||||||
|
let project_root_str = if ctx.is_gateway() {
|
||||||
|
ctx.services.project_root.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
ctx.effective_project_root()
|
||||||
|
.await
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let chat_fut = provider.chat_stream(
|
||||||
|
&prompt,
|
||||||
|
&project_root_str,
|
||||||
|
resume_session_id.as_deref(),
|
||||||
|
None,
|
||||||
|
&mut cancel_rx,
|
||||||
|
move |token| {
|
||||||
|
let mut buf = buffer_for_callback.lock().unwrap();
|
||||||
|
buf.push_str(token);
|
||||||
|
// Flush complete paragraphs as they arrive.
|
||||||
|
let paragraphs = drain_complete_paragraphs(&mut buf);
|
||||||
|
for chunk in paragraphs {
|
||||||
|
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
||||||
|
let _ = msg_tx_for_callback.send(chunk);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_thinking| {}, // Discard thinking tokens
|
||||||
|
|_activity| {}, // Discard activity signals
|
||||||
|
);
|
||||||
|
tokio::pin!(chat_fut);
|
||||||
|
|
||||||
|
// Lock the permission receiver for the duration of this chat session.
|
||||||
|
// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||||
|
let mut perm_rx_guard = ctx.services.perm_rx.lock().await;
|
||||||
|
|
||||||
|
let result = loop {
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut chat_fut => break r,
|
||||||
|
|
||||||
|
Some(perm_fwd) = perm_rx_guard.recv() => {
|
||||||
|
// Post the permission prompt to the room via the transport.
|
||||||
|
let prompt_msg = format!(
|
||||||
|
"**Permission Request**\n\n\
|
||||||
|
Tool: `{}`\n```json\n{}\n```\n\n\
|
||||||
|
Reply **yes** to approve or **no** to deny.",
|
||||||
|
perm_fwd.tool_name,
|
||||||
|
serde_json::to_string_pretty(&perm_fwd.tool_input)
|
||||||
|
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
|
||||||
|
);
|
||||||
|
let html = markdown_to_html(&prompt_msg);
|
||||||
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &prompt_msg, &html).await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
sent_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the MCP oneshot sender so the event handler can
|
||||||
|
// resolve it when the user replies yes/no.
|
||||||
|
ctx.services.pending_perm_replies
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(room_id.to_string(), perm_fwd.response_tx);
|
||||||
|
|
||||||
|
// Spawn a timeout task: auto-deny if the user does not respond.
|
||||||
|
let pending = Arc::clone(&ctx.services.pending_perm_replies);
|
||||||
|
let timeout_room_id = room_id.to_string();
|
||||||
|
let timeout_transport = Arc::clone(&ctx.transport);
|
||||||
|
let timeout_room_id_str = room_id_str.clone();
|
||||||
|
let timeout_sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
|
let timeout_secs = ctx.services.permission_timeout_secs;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
|
||||||
|
if let Some(tx) = pending.lock().await.remove(&timeout_room_id) {
|
||||||
|
let _ = tx.send(PermissionDecision::Deny);
|
||||||
|
let msg = "Permission request timed out — denied (fail-closed).";
|
||||||
|
let html = markdown_to_html(msg);
|
||||||
|
if let Ok(msg_id) = timeout_transport
|
||||||
|
.send_message(&timeout_room_id_str, msg, &html)
|
||||||
|
.await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
timeout_sent_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(perm_rx_guard);
|
||||||
|
|
||||||
|
// Flush any remaining text that didn't end with a paragraph boundary.
|
||||||
|
let remaining = buffer.lock().unwrap().trim().to_string();
|
||||||
|
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
let (assistant_reply, new_session_id) = match result {
|
||||||
|
Ok(ClaudeCodeResult {
|
||||||
|
messages,
|
||||||
|
session_id,
|
||||||
|
}) => {
|
||||||
|
let reply = if !remaining.is_empty() {
|
||||||
|
let _ = msg_tx.send(remaining.clone());
|
||||||
|
remaining
|
||||||
|
} else if !did_send_any {
|
||||||
|
// Nothing was streamed at all (e.g. only tool calls with no
|
||||||
|
// final text) — fall back to the last assistant message from
|
||||||
|
// the structured result.
|
||||||
|
let last_text = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||||
|
.map(|m| m.content.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !last_text.is_empty() {
|
||||||
|
let _ = msg_tx.send(last_text.clone());
|
||||||
|
}
|
||||||
|
last_text
|
||||||
|
} else {
|
||||||
|
remaining
|
||||||
|
};
|
||||||
|
slog!("[matrix-bot] session_id from chat_stream: {:?}", session_id);
|
||||||
|
(reply, session_id)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[matrix-bot] LLM error: {e}");
|
||||||
|
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||||
|
format!("Authentication required. [Click here to log in to Claude]({url})")
|
||||||
|
} else {
|
||||||
|
format!("Error processing your request: {e}")
|
||||||
|
};
|
||||||
|
let _ = msg_tx.send(err_msg.clone());
|
||||||
|
(err_msg, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop the sender to signal the posting task that no more messages will
|
||||||
|
// arrive, then wait for all pending Matrix sends to complete.
|
||||||
|
drop(msg_tx);
|
||||||
|
let _ = post_task.await;
|
||||||
|
|
||||||
|
// Record this exchange in the per-room conversation history and persist
|
||||||
|
// the session ID so the next turn resumes with structured API messages.
|
||||||
|
if !assistant_reply.starts_with("Error processing") {
|
||||||
|
let mut guard = ctx.history.lock().await;
|
||||||
|
let conv = guard.entry(room_id).or_default();
|
||||||
|
|
||||||
|
// Store the session ID so the next turn uses --resume.
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] storing session_id: {:?} (was: {:?})",
|
||||||
|
new_session_id,
|
||||||
|
conv.session_id
|
||||||
|
);
|
||||||
|
if new_session_id.is_some() {
|
||||||
|
conv.session_id = new_session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::User,
|
||||||
|
sender: sender.clone(),
|
||||||
|
content: user_message,
|
||||||
|
});
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::Assistant,
|
||||||
|
sender: String::new(),
|
||||||
|
content: assistant_reply,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trim to the configured maximum, dropping the oldest entries first.
|
||||||
|
// The session_id is preserved: Claude Code's --resume loads the full
|
||||||
|
// conversation from its own session transcript on disk, so trimming
|
||||||
|
// our local tracking doesn't affect the LLM's context.
|
||||||
|
if conv.entries.len() > ctx.history_size {
|
||||||
|
let excess = conv.entries.len() - ctx.history_size;
|
||||||
|
conv.entries.drain(..excess);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to disk so history survives server restarts.
|
||||||
|
save_history(&ctx.services.project_root, &guard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Matrix message handler — processes incoming room messages and dispatches commands.
|
||||||
|
|
||||||
|
mod handle_message;
|
||||||
|
mod on_room_message;
|
||||||
|
|
||||||
|
pub(in crate::chat::transport::matrix::bot) use handle_message::handle_message;
|
||||||
|
pub(in crate::chat::transport::matrix::bot) use on_room_message::on_room_message;
|
||||||
|
|
||||||
|
/// sender is included so the LLM can distinguish participants.
|
||||||
|
pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
|
||||||
|
format!("{sender}: {message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix event handler for room messages. Each invocation spawns an
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// -- format_user_prompt -------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_user_prompt_includes_sender_and_message() {
|
||||||
|
let prompt = format_user_prompt("@alice:example.com", "Hello!");
|
||||||
|
assert_eq!(prompt, "@alice:example.com: Hello!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_user_prompt_different_users() {
|
||||||
|
let prompt = format_user_prompt("@bob:example.com", "What's up?");
|
||||||
|
assert_eq!(prompt, "@bob:example.com: What's up?");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- OAuth login link formatting ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_error_produces_login_link() {
|
||||||
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
|
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||||
|
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||||
|
let msg = format!(
|
||||||
|
"Authentication required. [Click here to log in to Claude]({})",
|
||||||
|
url.unwrap()
|
||||||
|
);
|
||||||
|
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||||
|
assert!(msg.contains("[Click here to log in to Claude]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_oauth_error_not_formatted_as_link() {
|
||||||
|
let err = "Some unrelated error";
|
||||||
|
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- bot_name / system prompt -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bot_name_system_prompt_format() {
|
||||||
|
let bot_name = "Timmy";
|
||||||
|
let system_prompt =
|
||||||
|
format!("Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.");
|
||||||
|
assert_eq!(
|
||||||
|
system_prompt,
|
||||||
|
"Your name is Timmy. Refer to yourself as Timmy, not Claude."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-348
@@ -1,37 +1,27 @@
|
|||||||
//! Matrix message handler — processes incoming room messages and dispatches commands.
|
//! Matrix on_room_message handler — receives an incoming Matrix room event and
|
||||||
use crate::chat::util::{drain_complete_paragraphs, is_permission_approval};
|
//! dispatches it to handle_message after access checks and command routing.
|
||||||
|
|
||||||
|
use crate::chat::util::is_permission_approval;
|
||||||
use crate::http::context::PermissionDecision;
|
use crate::http::context::PermissionDecision;
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
Client,
|
Client,
|
||||||
event_handler::Ctx,
|
event_handler::Ctx,
|
||||||
room::Room,
|
room::Room,
|
||||||
ruma::{
|
ruma::{
|
||||||
OwnedRoomId,
|
|
||||||
events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
|
events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::watch;
|
|
||||||
|
|
||||||
use super::context::BotContext;
|
use super::super::context::BotContext;
|
||||||
use super::format::markdown_to_html;
|
use super::super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
|
||||||
use super::history::{ConversationEntry, ConversationRole, save_history};
|
use super::super::format::markdown_to_html;
|
||||||
use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
|
use super::super::verification::check_sender_verified;
|
||||||
use super::verification::check_sender_verified;
|
|
||||||
|
|
||||||
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
use super::handle_message;
|
||||||
/// sender is included so the LLM can distinguish participants.
|
|
||||||
pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
|
|
||||||
format!("{sender}: {message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matrix event handler for room messages. Each invocation spawns an
|
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||||
/// independent task so the sync loop is not blocked by LLM calls.
|
|
||||||
pub(super) async fn on_room_message(
|
|
||||||
ev: OriginalSyncRoomMessageEvent,
|
ev: OriginalSyncRoomMessageEvent,
|
||||||
room: Room,
|
room: Room,
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -261,14 +251,14 @@ pub(super) async fn on_room_message(
|
|||||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||||
// needed here.
|
// needed here.
|
||||||
let dispatch = super::super::commands::CommandDispatch {
|
let dispatch = super::super::super::commands::CommandDispatch {
|
||||||
services: &ctx.services,
|
services: &ctx.services,
|
||||||
project_root: &effective_root,
|
project_root: &effective_root,
|
||||||
bot_user_id: ctx.matrix_user_id.as_str(),
|
bot_user_id: ctx.matrix_user_id.as_str(),
|
||||||
room_id: &room_id_str,
|
room_id: &room_id_str,
|
||||||
};
|
};
|
||||||
if let Some((response, response_html)) =
|
if let Some((response, response_html)) =
|
||||||
super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
|
super::super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
|
||||||
{
|
{
|
||||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
@@ -284,20 +274,20 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the assign command, which requires async agent ops (stop +
|
// Check for the assign command, which requires async agent ops (stop +
|
||||||
// start) and cannot be handled by the sync command registry.
|
// start) and cannot be handled by the sync command registry.
|
||||||
if let Some(assign_cmd) = super::super::assign::extract_assign_command(
|
if let Some(assign_cmd) = super::super::super::assign::extract_assign_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match assign_cmd {
|
let response = match assign_cmd {
|
||||||
super::super::assign::AssignCommand::Assign {
|
super::super::super::assign::AssignCommand::Assign {
|
||||||
story_number,
|
story_number,
|
||||||
model,
|
model,
|
||||||
} => {
|
} => {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
|
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
|
||||||
);
|
);
|
||||||
super::super::assign::handle_assign(
|
super::super::super::assign::handle_assign(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
&model,
|
&model,
|
||||||
@@ -306,7 +296,7 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
super::super::assign::AssignCommand::BadArgs => {
|
super::super::super::assign::AssignCommand::BadArgs => {
|
||||||
format!(
|
format!(
|
||||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||||
ctx.services.bot_name
|
ctx.services.bot_name
|
||||||
@@ -327,23 +317,23 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the htop command, which requires async Matrix access (Room)
|
// Check for the htop command, which requires async Matrix access (Room)
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(htop_cmd) = super::super::htop::extract_htop_command(
|
if let Some(htop_cmd) = super::super::super::htop::extract_htop_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
slog!("[matrix-bot] Handling htop command from {sender}: {htop_cmd:?}");
|
slog!("[matrix-bot] Handling htop command from {sender}: {htop_cmd:?}");
|
||||||
match htop_cmd {
|
match htop_cmd {
|
||||||
super::super::htop::HtopCommand::Stop => {
|
super::super::super::htop::HtopCommand::Stop => {
|
||||||
super::super::htop::handle_htop_stop(
|
super::super::super::htop::handle_htop_stop(
|
||||||
&*ctx.transport,
|
&*ctx.transport,
|
||||||
&room_id_str,
|
&room_id_str,
|
||||||
&ctx.htop_sessions,
|
&ctx.htop_sessions,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
super::super::htop::HtopCommand::Start { duration_secs } => {
|
super::super::super::htop::HtopCommand::Start { duration_secs } => {
|
||||||
super::super::htop::handle_htop_start(
|
super::super::super::htop::handle_htop_start(
|
||||||
&ctx.transport,
|
&ctx.transport,
|
||||||
&room_id_str,
|
&room_id_str,
|
||||||
&ctx.htop_sessions,
|
&ctx.htop_sessions,
|
||||||
@@ -358,15 +348,15 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the delete command, which requires async agent/worktree ops
|
// Check for the delete command, which requires async agent/worktree ops
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(del_cmd) = super::super::delete::extract_delete_command(
|
if let Some(del_cmd) = super::super::super::delete::extract_delete_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match del_cmd {
|
let response = match del_cmd {
|
||||||
super::super::delete::DeleteCommand::Delete { story_number } => {
|
super::super::super::delete::DeleteCommand::Delete { story_number } => {
|
||||||
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
||||||
super::super::delete::handle_delete(
|
super::super::super::delete::handle_delete(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
&effective_root,
|
&effective_root,
|
||||||
@@ -374,7 +364,7 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
super::super::delete::DeleteCommand::BadArgs => {
|
super::super::super::delete::DeleteCommand::BadArgs => {
|
||||||
format!("Usage: `{} delete <number>`", ctx.services.bot_name)
|
format!("Usage: `{} delete <number>`", ctx.services.bot_name)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -392,15 +382,15 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the rmtree command, which requires async agent/worktree ops
|
// Check for the rmtree command, which requires async agent/worktree ops
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(rmtree_cmd) = super::super::rmtree::extract_rmtree_command(
|
if let Some(rmtree_cmd) = super::super::super::rmtree::extract_rmtree_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match rmtree_cmd {
|
let response = match rmtree_cmd {
|
||||||
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
super::super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||||
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
||||||
super::super::rmtree::handle_rmtree(
|
super::super::super::rmtree::handle_rmtree(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
&effective_root,
|
&effective_root,
|
||||||
@@ -408,7 +398,7 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
super::super::rmtree::RmtreeCommand::BadArgs => {
|
super::super::super::rmtree::RmtreeCommand::BadArgs => {
|
||||||
format!("Usage: `{} rmtree <number>`", ctx.services.bot_name)
|
format!("Usage: `{} rmtree <number>`", ctx.services.bot_name)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -426,20 +416,20 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the start command, which requires async agent ops and cannot
|
// Check for the start command, which requires async agent ops and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(start_cmd) = super::super::start::extract_start_command(
|
if let Some(start_cmd) = super::super::super::start::extract_start_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match start_cmd {
|
let response = match start_cmd {
|
||||||
super::super::start::StartCommand::Start {
|
super::super::super::start::StartCommand::Start {
|
||||||
story_number,
|
story_number,
|
||||||
agent_hint,
|
agent_hint,
|
||||||
} => {
|
} => {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
|
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
|
||||||
);
|
);
|
||||||
super::super::start::handle_start(
|
super::super::super::start::handle_start(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
agent_hint.as_deref(),
|
agent_hint.as_deref(),
|
||||||
@@ -448,7 +438,7 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
super::super::start::StartCommand::BadArgs => {
|
super::super::super::start::StartCommand::BadArgs => {
|
||||||
format!(
|
format!(
|
||||||
"Usage: `{} start <number>` or `{} start <number> opus`",
|
"Usage: `{} start <number>` or `{} start <number> opus`",
|
||||||
ctx.services.bot_name, ctx.services.bot_name
|
ctx.services.bot_name, ctx.services.bot_name
|
||||||
@@ -469,7 +459,7 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the reset command, which requires async access to the shared
|
// Check for the reset command, which requires async access to the shared
|
||||||
// conversation history and cannot be handled by the sync command registry.
|
// conversation history and cannot be handled by the sync command registry.
|
||||||
if super::super::reset::extract_reset_command(
|
if super::super::super::reset::extract_reset_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
@@ -477,7 +467,7 @@ pub(super) async fn on_room_message(
|
|||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
slog!("[matrix-bot] Handling reset command from {sender}");
|
slog!("[matrix-bot] Handling reset command from {sender}");
|
||||||
let response = super::super::reset::handle_reset(
|
let response = super::super::super::reset::handle_reset(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&incoming_room_id,
|
&incoming_room_id,
|
||||||
&ctx.history,
|
&ctx.history,
|
||||||
@@ -498,7 +488,7 @@ pub(super) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the rebuild command, which requires async agent and process ops
|
// Check for the rebuild command, which requires async agent and process ops
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if super::super::rebuild::extract_rebuild_command(
|
if super::super::super::rebuild::extract_rebuild_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
@@ -517,7 +507,7 @@ pub(super) async fn on_room_message(
|
|||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
}
|
}
|
||||||
let response = super::super::rebuild::handle_rebuild(
|
let response = super::super::super::rebuild::handle_rebuild(
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
&ctx.services.project_root,
|
&ctx.services.project_root,
|
||||||
&ctx.services.agents,
|
&ctx.services.agents,
|
||||||
@@ -609,304 +599,3 @@ pub(super) async fn on_room_message(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_message(
|
|
||||||
room_id_str: String,
|
|
||||||
room_id: OwnedRoomId,
|
|
||||||
ctx: BotContext,
|
|
||||||
sender: String,
|
|
||||||
user_message: String,
|
|
||||||
) {
|
|
||||||
// Look up the room's existing Claude Code session ID (if any) so we can
|
|
||||||
// resume the conversation with structured API messages instead of
|
|
||||||
// flattening history into a text prefix.
|
|
||||||
let resume_session_id: Option<String> = {
|
|
||||||
let guard = ctx.history.lock().await;
|
|
||||||
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
// The prompt is just the current message with sender attribution.
|
|
||||||
// Prior conversation context is carried by the Claude Code session.
|
|
||||||
let bot_name = &ctx.services.bot_name;
|
|
||||||
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
|
|
||||||
let name = ap.read().await.clone();
|
|
||||||
format!("[Active project: {name}]\n")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let prompt = format!(
|
|
||||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
|
||||||
format_user_prompt(&sender, &user_message)
|
|
||||||
);
|
|
||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
|
||||||
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
||||||
// Keep the sender alive for the duration of the call.
|
|
||||||
let _cancel_tx = cancel_tx;
|
|
||||||
|
|
||||||
// Channel for sending complete paragraphs to the Matrix posting task.
|
|
||||||
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
|
||||||
let msg_tx_for_callback = msg_tx.clone();
|
|
||||||
|
|
||||||
// Spawn a task to post messages via the transport as they arrive so we
|
|
||||||
// don't block the LLM stream while waiting for send round-trips.
|
|
||||||
let post_transport = Arc::clone(&ctx.transport);
|
|
||||||
let post_room_id = room_id_str.clone();
|
|
||||||
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
|
||||||
let sent_ids_for_post = Arc::clone(&sent_ids);
|
|
||||||
let post_task = tokio::spawn(async move {
|
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
|
||||||
let html = markdown_to_html(&chunk);
|
|
||||||
if let Ok(msg_id) = post_transport
|
|
||||||
.send_message(&post_room_id, &chunk, &html)
|
|
||||||
.await
|
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
|
||||||
{
|
|
||||||
sent_ids_for_post.lock().await.insert(event_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shared state between the sync token callback and the async outer scope.
|
|
||||||
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
|
|
||||||
let buffer_for_callback = Arc::clone(&buffer);
|
|
||||||
let sent_any_chunk = Arc::new(AtomicBool::new(false));
|
|
||||||
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
|
|
||||||
|
|
||||||
// In gateway mode, run Claude Code in the gateway config directory so it
|
|
||||||
// picks up the `.mcp.json` that points to the gateway's MCP proxy endpoint.
|
|
||||||
// The gateway proxies tool calls to the active project automatically.
|
|
||||||
// In standalone mode, use the project root directly.
|
|
||||||
let project_root_str = if ctx.is_gateway() {
|
|
||||||
ctx.services.project_root.to_string_lossy().to_string()
|
|
||||||
} else {
|
|
||||||
ctx.effective_project_root()
|
|
||||||
.await
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string()
|
|
||||||
};
|
|
||||||
let chat_fut = provider.chat_stream(
|
|
||||||
&prompt,
|
|
||||||
&project_root_str,
|
|
||||||
resume_session_id.as_deref(),
|
|
||||||
None,
|
|
||||||
&mut cancel_rx,
|
|
||||||
move |token| {
|
|
||||||
let mut buf = buffer_for_callback.lock().unwrap();
|
|
||||||
buf.push_str(token);
|
|
||||||
// Flush complete paragraphs as they arrive.
|
|
||||||
let paragraphs = drain_complete_paragraphs(&mut buf);
|
|
||||||
for chunk in paragraphs {
|
|
||||||
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
|
||||||
let _ = msg_tx_for_callback.send(chunk);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|_thinking| {}, // Discard thinking tokens
|
|
||||||
|_activity| {}, // Discard activity signals
|
|
||||||
);
|
|
||||||
tokio::pin!(chat_fut);
|
|
||||||
|
|
||||||
// Lock the permission receiver for the duration of this chat session.
|
|
||||||
// Permission requests from the MCP `prompt_permission` tool arrive here.
|
|
||||||
let mut perm_rx_guard = ctx.services.perm_rx.lock().await;
|
|
||||||
|
|
||||||
let result = loop {
|
|
||||||
tokio::select! {
|
|
||||||
r = &mut chat_fut => break r,
|
|
||||||
|
|
||||||
Some(perm_fwd) = perm_rx_guard.recv() => {
|
|
||||||
// Post the permission prompt to the room via the transport.
|
|
||||||
let prompt_msg = format!(
|
|
||||||
"**Permission Request**\n\n\
|
|
||||||
Tool: `{}`\n```json\n{}\n```\n\n\
|
|
||||||
Reply **yes** to approve or **no** to deny.",
|
|
||||||
perm_fwd.tool_name,
|
|
||||||
serde_json::to_string_pretty(&perm_fwd.tool_input)
|
|
||||||
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
|
|
||||||
);
|
|
||||||
let html = markdown_to_html(&prompt_msg);
|
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &prompt_msg, &html).await
|
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
|
||||||
{
|
|
||||||
sent_ids.lock().await.insert(event_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the MCP oneshot sender so the event handler can
|
|
||||||
// resolve it when the user replies yes/no.
|
|
||||||
ctx.services.pending_perm_replies
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(room_id.to_string(), perm_fwd.response_tx);
|
|
||||||
|
|
||||||
// Spawn a timeout task: auto-deny if the user does not respond.
|
|
||||||
let pending = Arc::clone(&ctx.services.pending_perm_replies);
|
|
||||||
let timeout_room_id = room_id.to_string();
|
|
||||||
let timeout_transport = Arc::clone(&ctx.transport);
|
|
||||||
let timeout_room_id_str = room_id_str.clone();
|
|
||||||
let timeout_sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
|
||||||
let timeout_secs = ctx.services.permission_timeout_secs;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
|
|
||||||
if let Some(tx) = pending.lock().await.remove(&timeout_room_id) {
|
|
||||||
let _ = tx.send(PermissionDecision::Deny);
|
|
||||||
let msg = "Permission request timed out — denied (fail-closed).";
|
|
||||||
let html = markdown_to_html(msg);
|
|
||||||
if let Ok(msg_id) = timeout_transport
|
|
||||||
.send_message(&timeout_room_id_str, msg, &html)
|
|
||||||
.await
|
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
|
||||||
{
|
|
||||||
timeout_sent_ids.lock().await.insert(event_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
drop(perm_rx_guard);
|
|
||||||
|
|
||||||
// Flush any remaining text that didn't end with a paragraph boundary.
|
|
||||||
let remaining = buffer.lock().unwrap().trim().to_string();
|
|
||||||
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
let (assistant_reply, new_session_id) = match result {
|
|
||||||
Ok(ClaudeCodeResult {
|
|
||||||
messages,
|
|
||||||
session_id,
|
|
||||||
}) => {
|
|
||||||
let reply = if !remaining.is_empty() {
|
|
||||||
let _ = msg_tx.send(remaining.clone());
|
|
||||||
remaining
|
|
||||||
} else if !did_send_any {
|
|
||||||
// Nothing was streamed at all (e.g. only tool calls with no
|
|
||||||
// final text) — fall back to the last assistant message from
|
|
||||||
// the structured result.
|
|
||||||
let last_text = messages
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
|
||||||
.map(|m| m.content.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !last_text.is_empty() {
|
|
||||||
let _ = msg_tx.send(last_text.clone());
|
|
||||||
}
|
|
||||||
last_text
|
|
||||||
} else {
|
|
||||||
remaining
|
|
||||||
};
|
|
||||||
slog!("[matrix-bot] session_id from chat_stream: {:?}", session_id);
|
|
||||||
(reply, session_id)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
slog!("[matrix-bot] LLM error: {e}");
|
|
||||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
|
||||||
format!("Authentication required. [Click here to log in to Claude]({url})")
|
|
||||||
} else {
|
|
||||||
format!("Error processing your request: {e}")
|
|
||||||
};
|
|
||||||
let _ = msg_tx.send(err_msg.clone());
|
|
||||||
(err_msg, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Drop the sender to signal the posting task that no more messages will
|
|
||||||
// arrive, then wait for all pending Matrix sends to complete.
|
|
||||||
drop(msg_tx);
|
|
||||||
let _ = post_task.await;
|
|
||||||
|
|
||||||
// Record this exchange in the per-room conversation history and persist
|
|
||||||
// the session ID so the next turn resumes with structured API messages.
|
|
||||||
if !assistant_reply.starts_with("Error processing") {
|
|
||||||
let mut guard = ctx.history.lock().await;
|
|
||||||
let conv = guard.entry(room_id).or_default();
|
|
||||||
|
|
||||||
// Store the session ID so the next turn uses --resume.
|
|
||||||
slog!(
|
|
||||||
"[matrix-bot] storing session_id: {:?} (was: {:?})",
|
|
||||||
new_session_id,
|
|
||||||
conv.session_id
|
|
||||||
);
|
|
||||||
if new_session_id.is_some() {
|
|
||||||
conv.session_id = new_session_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
conv.entries.push(ConversationEntry {
|
|
||||||
role: ConversationRole::User,
|
|
||||||
sender: sender.clone(),
|
|
||||||
content: user_message,
|
|
||||||
});
|
|
||||||
conv.entries.push(ConversationEntry {
|
|
||||||
role: ConversationRole::Assistant,
|
|
||||||
sender: String::new(),
|
|
||||||
content: assistant_reply,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trim to the configured maximum, dropping the oldest entries first.
|
|
||||||
// The session_id is preserved: Claude Code's --resume loads the full
|
|
||||||
// conversation from its own session transcript on disk, so trimming
|
|
||||||
// our local tracking doesn't affect the LLM's context.
|
|
||||||
if conv.entries.len() > ctx.history_size {
|
|
||||||
let excess = conv.entries.len() - ctx.history_size;
|
|
||||||
conv.entries.drain(..excess);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist to disk so history survives server restarts.
|
|
||||||
save_history(&ctx.services.project_root, &guard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// -- format_user_prompt -------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_user_prompt_includes_sender_and_message() {
|
|
||||||
let prompt = format_user_prompt("@alice:example.com", "Hello!");
|
|
||||||
assert_eq!(prompt, "@alice:example.com: Hello!");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_user_prompt_different_users() {
|
|
||||||
let prompt = format_user_prompt("@bob:example.com", "What's up?");
|
|
||||||
assert_eq!(prompt, "@bob:example.com: What's up?");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- OAuth login link formatting ----------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oauth_error_produces_login_link() {
|
|
||||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
|
||||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
|
||||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
|
||||||
let msg = format!(
|
|
||||||
"Authentication required. [Click here to log in to Claude]({})",
|
|
||||||
url.unwrap()
|
|
||||||
);
|
|
||||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
|
||||||
assert!(msg.contains("[Click here to log in to Claude]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_oauth_error_not_formatted_as_link() {
|
|
||||||
let err = "Some unrelated error";
|
|
||||||
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- bot_name / system prompt -------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bot_name_system_prompt_format() {
|
|
||||||
let bot_name = "Timmy";
|
|
||||||
let system_prompt =
|
|
||||||
format!("Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.");
|
|
||||||
assert_eq!(
|
|
||||||
system_prompt,
|
|
||||||
"Your name is Timmy. Refer to yourself as Timmy, not Claude."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user