use super::shared;
use crate::{
core::{
extensions::{Extension, ExtensionCommand, ExtensionMode, ExtensionTool, UiNotifyLevel},
provider::AgentMessage,
session_manager::Session,
},
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_RECAP,
util::{notify_text, session_id_of},
},
modes::interactive::{
app::{App, MsgLevel},
handlers::register_slash_command,
},
utils::{glyphs::DEF_RECAP, truncate::truncate_chars},
};
use std::sync::Arc;
const EXT: &str = "recap";
const SYSTEM: &str = "Summarize this coding session in ONE line of at most 120 characters. \
Say what is being worked on and where it stands. No preamble, no \
markdown heading, no trailing period padding.";
const EXCERPT_MESSAGES: usize = 12;
const EXCERPT_CHARS: usize = 12000;
const MAX_TOKENS: u32 = 160;
const MAX_CHARS: usize = 120;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static RECAP_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_RECAP,
make: || -> Arc<dyn Extension> { Arc::new(Recap) },
};
pub struct Recap;
impl Extension for Recap {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"One-line session recap as a system message (/recap)"
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: "recap".to_string(),
description: "/recap (one-line session summary as a system message)".to_string(),
busy_safe: true, subcommands: Vec::new(),
}]
}
fn on_registered(&self) {
register_slash_command(EXT, "recap", command_recap);
}
fn on_session_start(&self, _cwd: &str, _messages: &[AgentMessage], session: Option<&Session>) {
shared::remember_session(session.map(|s| s.session_id.clone()));
}
fn on_session_switched(&self, session_path: Option<&str>, _messages: &[AgentMessage]) {
shared::remember_session(session_id_of(session_path));
}
}
fn command_recap(st: &mut App, _raw: &str) -> bool {
shared::refresh_session_from_app(st);
let Some((provider, model_id)) = shared::current_model(st) else {
st.push_msg(
"No model is selected yet. Pick one with /model, then try again.".to_string(),
MsgLevel::Warning,
);
return false;
};
let excerpt = shared::build_excerpt(&st.messages, EXCERPT_MESSAGES, EXCERPT_CHARS);
if excerpt.trim().is_empty() {
st.push_msg(
"Nothing to summarize yet — this session has no messages.".to_string(),
MsgLevel::Info,
);
return false;
}
st.set_status_msg("creating recap…");
if let Err(e) =
shared::spawn_one_shot(provider, model_id, SYSTEM, excerpt, MAX_TOKENS, |result| {
match result {
Ok(text) => notify_text(
&format!("{DEF_RECAP} recap: {}", normalize_recap(&text)),
UiNotifyLevel::Info,
),
Err(e) => notify_text(&format!("recap failed: {e}"), UiNotifyLevel::Warning),
}
})
{
notify_text(&format!("recap failed: {e}"), UiNotifyLevel::Warning);
}
false
}
fn normalize_recap(text: &str) -> String {
let mut cleaned = text.trim().to_string();
for prefix in [
DEF_RECAP, "recap:", "Recap:", "recap :", "SUMMARY:", "Summary:",
] {
if let Some(rest) = cleaned.strip_prefix(prefix) {
cleaned = rest.trim().to_string();
}
}
cleaned = cleaned
.trim_matches(|c| c == '"' || c == '\'' || c == '“' || c == '”')
.trim()
.to_string();
cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_chars(&cleaned, MAX_CHARS, None, false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{core::extensions::take_pending_ui, test_support::AUTH_TEST_LOCK};
use std::sync::MutexGuard;
fn lock_global() -> MutexGuard<'static, ()> {
AUTH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn message(role: &str, text: &str) -> AgentMessage {
let mut message = AgentMessage::user_text(text);
message.role = role.to_string();
message
}
#[test]
fn normalize_recap_strips_prefix_quotes_and_caps_length() {
assert_eq!(
normalize_recap(" Recap: working on the parser "),
"working on the parser"
);
assert_eq!(normalize_recap("※ \"done\" "), "done");
assert_eq!(normalize_recap("a\n b"), "a b", "换行与多空格归一");
let long = normalize_recap(&"字".repeat(200));
assert_eq!(long.chars().count(), MAX_CHARS, "硬截断到上限");
}
#[test]
fn recap_without_model_hints() {
let _g = lock_global();
let mut st = App::new();
st.current_provider = None;
st.current_model = None;
assert!(!command_recap(&mut st, "recap"));
assert!(!st.system_messages.is_empty(), "没有型号应提示");
}
#[test]
fn recap_without_history_hints() {
let _g = lock_global();
let mut st = App::new();
st.current_provider = Some("anthropic".to_string());
st.current_model = Some("claude-fable-5".to_string());
assert!(!command_recap(&mut st, "recap"));
assert!(!st.system_messages.is_empty(), "没有历史应提示");
}
#[test]
fn recap_without_runtime_notifies_failure() {
let _g = lock_global();
while take_pending_ui().is_some() {}
let mut st = App::new();
st.current_provider = Some("anthropic".to_string());
st.current_model = Some("claude-fable-5".to_string());
st.messages.push(message("user", "hello world"));
assert!(!command_recap(&mut st, "recap"));
assert!(matches!(
take_pending_ui(),
Some(crate::core::extensions::ExtensionUiRequest::NotifyRich {
level: UiNotifyLevel::Warning,
..
})
));
while take_pending_ui().is_some() {}
}
#[test]
fn command_and_mode_surface() {
let cmds = Recap.commands();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name, "recap");
assert!(cmds[0].busy_safe);
assert!(cmds[0].subcommands.is_empty(), "没有子命令");
assert_eq!(Recap.name(), EXT);
assert!(!Recap.default_enabled(), "默认不启用");
assert_eq!(
Recap.modes(),
vec![ExtensionMode::Dev, ExtensionMode::Creator],
"只在 Dev / Creator 模式可用"
);
assert!(Recap.tools().is_empty());
assert!(Recap.hooks().is_empty(), "不再提供 dock 等钩子");
}
}