mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 18:31:51 +08:00
Extended thinking needed to travel end-to-end through the API, runtime, and CLI so the client can request a thinking budget, preserve streamed reasoning blocks, and present them in a collapsed text-first form. The implementation keeps thinking strictly opt-in, adds a session-local toggle, and reuses the existing flag/slash-command/reporting surfaces instead of introducing a new UI layer. Constraint: Existing non-thinking text/tool flows had to remain backward compatible by default Constraint: Terminal UX needed a lightweight collapsed representation rather than an interactive TUI widget Rejected: Heuristic CLI-only parsing of reasoning text | brittle against structured stream payloads Rejected: Expanded raw thinking output by default | too noisy for normal assistant responses Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep thinking blocks structurally separate from answer text unless the upstream API contract changes Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test -q Not-tested: Live upstream thinking payloads against the production API contract
492 lines
16 KiB
Rust
492 lines
16 KiB
Rust
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct CompactionConfig {
|
|
pub preserve_recent_messages: usize,
|
|
pub max_estimated_tokens: usize,
|
|
}
|
|
|
|
impl Default for CompactionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
preserve_recent_messages: 4,
|
|
max_estimated_tokens: 10_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CompactionResult {
|
|
pub summary: String,
|
|
pub formatted_summary: String,
|
|
pub compacted_session: Session,
|
|
pub removed_message_count: usize,
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn estimate_session_tokens(session: &Session) -> usize {
|
|
session.messages.iter().map(estimate_message_tokens).sum()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
|
|
session.messages.len() > config.preserve_recent_messages
|
|
&& estimate_session_tokens(session) >= config.max_estimated_tokens
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn format_compact_summary(summary: &str) -> String {
|
|
let without_analysis = strip_tag_block(summary, "analysis");
|
|
let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
|
|
without_analysis.replace(
|
|
&format!("<summary>{content}</summary>"),
|
|
&format!("Summary:\n{}", content.trim()),
|
|
)
|
|
} else {
|
|
without_analysis
|
|
};
|
|
|
|
collapse_blank_lines(&formatted).trim().to_string()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_compact_continuation_message(
|
|
summary: &str,
|
|
suppress_follow_up_questions: bool,
|
|
recent_messages_preserved: bool,
|
|
) -> String {
|
|
let mut base = format!(
|
|
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
|
|
format_compact_summary(summary)
|
|
);
|
|
|
|
if recent_messages_preserved {
|
|
base.push_str("\n\nRecent messages are preserved verbatim.");
|
|
}
|
|
|
|
if suppress_follow_up_questions {
|
|
base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
|
|
}
|
|
|
|
base
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
|
|
if !should_compact(session, config) {
|
|
return CompactionResult {
|
|
summary: String::new(),
|
|
formatted_summary: String::new(),
|
|
compacted_session: session.clone(),
|
|
removed_message_count: 0,
|
|
};
|
|
}
|
|
|
|
let keep_from = session
|
|
.messages
|
|
.len()
|
|
.saturating_sub(config.preserve_recent_messages);
|
|
let removed = &session.messages[..keep_from];
|
|
let preserved = session.messages[keep_from..].to_vec();
|
|
let summary = summarize_messages(removed);
|
|
let formatted_summary = format_compact_summary(&summary);
|
|
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
|
|
|
|
let mut compacted_messages = vec![ConversationMessage {
|
|
role: MessageRole::System,
|
|
blocks: vec![ContentBlock::Text { text: continuation }],
|
|
usage: None,
|
|
}];
|
|
compacted_messages.extend(preserved);
|
|
|
|
CompactionResult {
|
|
summary,
|
|
formatted_summary,
|
|
compacted_session: Session {
|
|
version: session.version,
|
|
messages: compacted_messages,
|
|
},
|
|
removed_message_count: removed.len(),
|
|
}
|
|
}
|
|
|
|
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|
let user_messages = messages
|
|
.iter()
|
|
.filter(|message| message.role == MessageRole::User)
|
|
.count();
|
|
let assistant_messages = messages
|
|
.iter()
|
|
.filter(|message| message.role == MessageRole::Assistant)
|
|
.count();
|
|
let tool_messages = messages
|
|
.iter()
|
|
.filter(|message| message.role == MessageRole::Tool)
|
|
.count();
|
|
|
|
let mut tool_names = messages
|
|
.iter()
|
|
.flat_map(|message| message.blocks.iter())
|
|
.filter_map(|block| match block {
|
|
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
|
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
|
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
tool_names.sort_unstable();
|
|
tool_names.dedup();
|
|
|
|
let mut lines = vec![
|
|
"<summary>".to_string(),
|
|
"Conversation summary:".to_string(),
|
|
format!(
|
|
"- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
|
|
messages.len(),
|
|
user_messages,
|
|
assistant_messages,
|
|
tool_messages
|
|
),
|
|
];
|
|
|
|
if !tool_names.is_empty() {
|
|
lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
|
|
}
|
|
|
|
let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
|
|
if !recent_user_requests.is_empty() {
|
|
lines.push("- Recent user requests:".to_string());
|
|
lines.extend(
|
|
recent_user_requests
|
|
.into_iter()
|
|
.map(|request| format!(" - {request}")),
|
|
);
|
|
}
|
|
|
|
let pending_work = infer_pending_work(messages);
|
|
if !pending_work.is_empty() {
|
|
lines.push("- Pending work:".to_string());
|
|
lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
|
|
}
|
|
|
|
let key_files = collect_key_files(messages);
|
|
if !key_files.is_empty() {
|
|
lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
|
|
}
|
|
|
|
if let Some(current_work) = infer_current_work(messages) {
|
|
lines.push(format!("- Current work: {current_work}"));
|
|
}
|
|
|
|
lines.push("- Key timeline:".to_string());
|
|
for message in messages {
|
|
let role = match message.role {
|
|
MessageRole::System => "system",
|
|
MessageRole::User => "user",
|
|
MessageRole::Assistant => "assistant",
|
|
MessageRole::Tool => "tool",
|
|
};
|
|
let content = message
|
|
.blocks
|
|
.iter()
|
|
.map(summarize_block)
|
|
.collect::<Vec<_>>()
|
|
.join(" | ");
|
|
lines.push(format!(" - {role}: {content}"));
|
|
}
|
|
lines.push("</summary>".to_string());
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn summarize_block(block: &ContentBlock) -> String {
|
|
let raw = match block {
|
|
ContentBlock::Text { text } => text.clone(),
|
|
ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
|
|
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
|
ContentBlock::ToolResult {
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
..
|
|
} => format!(
|
|
"tool_result {tool_name}: {}{output}",
|
|
if *is_error { "error " } else { "" }
|
|
),
|
|
};
|
|
truncate_summary(&raw, 160)
|
|
}
|
|
|
|
fn collect_recent_role_summaries(
|
|
messages: &[ConversationMessage],
|
|
role: MessageRole,
|
|
limit: usize,
|
|
) -> Vec<String> {
|
|
messages
|
|
.iter()
|
|
.filter(|message| message.role == role)
|
|
.rev()
|
|
.filter_map(|message| first_text_block(message))
|
|
.take(limit)
|
|
.map(|text| truncate_summary(text, 160))
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect()
|
|
}
|
|
|
|
fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
|
|
messages
|
|
.iter()
|
|
.rev()
|
|
.filter_map(first_text_block)
|
|
.filter(|text| {
|
|
let lowered = text.to_ascii_lowercase();
|
|
lowered.contains("todo")
|
|
|| lowered.contains("next")
|
|
|| lowered.contains("pending")
|
|
|| lowered.contains("follow up")
|
|
|| lowered.contains("remaining")
|
|
})
|
|
.take(3)
|
|
.map(|text| truncate_summary(text, 160))
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect()
|
|
}
|
|
|
|
fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
|
let mut files = messages
|
|
.iter()
|
|
.flat_map(|message| message.blocks.iter())
|
|
.map(|block| match block {
|
|
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(),
|
|
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
|
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
|
})
|
|
.flat_map(extract_file_candidates)
|
|
.collect::<Vec<_>>();
|
|
files.sort();
|
|
files.dedup();
|
|
files.into_iter().take(8).collect()
|
|
}
|
|
|
|
fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
|
|
messages
|
|
.iter()
|
|
.rev()
|
|
.filter_map(first_text_block)
|
|
.find(|text| !text.trim().is_empty())
|
|
.map(|text| truncate_summary(text, 200))
|
|
}
|
|
|
|
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
|
message.blocks.iter().find_map(|block| match block {
|
|
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. }
|
|
if !text.trim().is_empty() =>
|
|
{
|
|
Some(text.as_str())
|
|
}
|
|
ContentBlock::ToolUse { .. }
|
|
| ContentBlock::ToolResult { .. }
|
|
| ContentBlock::Text { .. }
|
|
| ContentBlock::Thinking { .. } => None,
|
|
})
|
|
}
|
|
|
|
fn has_interesting_extension(candidate: &str) -> bool {
|
|
std::path::Path::new(candidate)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| {
|
|
["rs", "ts", "tsx", "js", "json", "md"]
|
|
.iter()
|
|
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
|
})
|
|
}
|
|
|
|
fn extract_file_candidates(content: &str) -> Vec<String> {
|
|
content
|
|
.split_whitespace()
|
|
.filter_map(|token| {
|
|
let candidate = token.trim_matches(|char: char| {
|
|
matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
|
|
});
|
|
if candidate.contains('/') && has_interesting_extension(candidate) {
|
|
Some(candidate.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn truncate_summary(content: &str, max_chars: usize) -> String {
|
|
if content.chars().count() <= max_chars {
|
|
return content.to_string();
|
|
}
|
|
let mut truncated = content.chars().take(max_chars).collect::<String>();
|
|
truncated.push('…');
|
|
truncated
|
|
}
|
|
|
|
fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
|
message
|
|
.blocks
|
|
.iter()
|
|
.map(|block| match block {
|
|
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1,
|
|
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
|
ContentBlock::ToolResult {
|
|
tool_name, output, ..
|
|
} => (tool_name.len() + output.len()) / 4 + 1,
|
|
})
|
|
.sum()
|
|
}
|
|
|
|
fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
|
|
let start = format!("<{tag}>");
|
|
let end = format!("</{tag}>");
|
|
let start_index = content.find(&start)? + start.len();
|
|
let end_index = content[start_index..].find(&end)? + start_index;
|
|
Some(content[start_index..end_index].to_string())
|
|
}
|
|
|
|
fn strip_tag_block(content: &str, tag: &str) -> String {
|
|
let start = format!("<{tag}>");
|
|
let end = format!("</{tag}>");
|
|
if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
|
|
let end_index = end_index_rel + end.len();
|
|
let mut stripped = String::new();
|
|
stripped.push_str(&content[..start_index]);
|
|
stripped.push_str(&content[end_index..]);
|
|
stripped
|
|
} else {
|
|
content.to_string()
|
|
}
|
|
}
|
|
|
|
fn collapse_blank_lines(content: &str) -> String {
|
|
let mut result = String::new();
|
|
let mut last_blank = false;
|
|
for line in content.lines() {
|
|
let is_blank = line.trim().is_empty();
|
|
if is_blank && last_blank {
|
|
continue;
|
|
}
|
|
result.push_str(line);
|
|
result.push('\n');
|
|
last_blank = is_blank;
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
|
infer_pending_work, should_compact, CompactionConfig,
|
|
};
|
|
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
|
|
|
#[test]
|
|
fn formats_compact_summary_like_upstream() {
|
|
let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
|
|
assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
|
|
}
|
|
|
|
#[test]
|
|
fn leaves_small_sessions_unchanged() {
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![ConversationMessage::user_text("hello")],
|
|
};
|
|
|
|
let result = compact_session(&session, CompactionConfig::default());
|
|
assert_eq!(result.removed_message_count, 0);
|
|
assert_eq!(result.compacted_session, session);
|
|
assert!(result.summary.is_empty());
|
|
assert!(result.formatted_summary.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn compacts_older_messages_into_a_system_summary() {
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![
|
|
ConversationMessage::user_text("one ".repeat(200)),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "two ".repeat(200),
|
|
}]),
|
|
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
|
|
ConversationMessage {
|
|
role: MessageRole::Assistant,
|
|
blocks: vec![ContentBlock::Text {
|
|
text: "recent".to_string(),
|
|
}],
|
|
usage: None,
|
|
},
|
|
],
|
|
};
|
|
|
|
let result = compact_session(
|
|
&session,
|
|
CompactionConfig {
|
|
preserve_recent_messages: 2,
|
|
max_estimated_tokens: 1,
|
|
},
|
|
);
|
|
|
|
assert_eq!(result.removed_message_count, 2);
|
|
assert_eq!(
|
|
result.compacted_session.messages[0].role,
|
|
MessageRole::System
|
|
);
|
|
assert!(matches!(
|
|
&result.compacted_session.messages[0].blocks[0],
|
|
ContentBlock::Text { text } if text.contains("Summary:")
|
|
));
|
|
assert!(result.formatted_summary.contains("Scope:"));
|
|
assert!(result.formatted_summary.contains("Key timeline:"));
|
|
assert!(should_compact(
|
|
&session,
|
|
CompactionConfig {
|
|
preserve_recent_messages: 2,
|
|
max_estimated_tokens: 1,
|
|
}
|
|
));
|
|
assert!(
|
|
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncates_long_blocks_in_summary() {
|
|
let summary = super::summarize_block(&ContentBlock::Text {
|
|
text: "x".repeat(400),
|
|
});
|
|
assert!(summary.ends_with('…'));
|
|
assert!(summary.chars().count() <= 161);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_key_files_from_message_content() {
|
|
let files = collect_key_files(&[ConversationMessage::user_text(
|
|
"Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
|
|
)]);
|
|
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
|
|
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn infers_pending_work_from_recent_messages() {
|
|
let pending = infer_pending_work(&[
|
|
ConversationMessage::user_text("done"),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
|
|
}]),
|
|
]);
|
|
assert_eq!(pending.len(), 1);
|
|
assert!(pending[0].contains("Next: update tests"));
|
|
}
|
|
}
|