mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 01:01:52 +08:00
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic - tools: extended tool suite (WebSearch, WebFetch, Agent, etc.) - cli: live streamed conversations, session restore, compact commands - runtime: config loading, system prompt builder, token usage, compaction
This commit is contained in:
291
rust/crates/runtime/src/compact.rs
Normal file
291
rust/crates/runtime/src/compact.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
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 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(),
|
||||
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 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,
|
||||
compacted_session: Session {
|
||||
version: session.version,
|
||||
messages: compacted_messages,
|
||||
},
|
||||
removed_message_count: removed.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".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::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 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 } => 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::{
|
||||
compact_session, estimate_session_tokens, format_compact_summary, 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());
|
||||
}
|
||||
|
||||
#[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!(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);
|
||||
}
|
||||
}
|
||||
269
rust/crates/runtime/src/config.rs
Normal file
269
rust/crates/runtime/src/config.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::json::JsonValue;
|
||||
|
||||
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ConfigSource {
|
||||
User,
|
||||
Project,
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigEntry {
|
||||
pub source: ConfigSource,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeConfig {
|
||||
merged: BTreeMap<String, JsonValue>,
|
||||
loaded_entries: Vec<ConfigEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Io(std::io::Error),
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl Display for ConfigError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Parse(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl From<std::io::Error> for ConfigError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigLoader {
|
||||
cwd: PathBuf,
|
||||
config_home: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigLoader {
|
||||
#[must_use]
|
||||
pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
cwd: cwd.into(),
|
||||
config_home: config_home.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
||||
let cwd = cwd.into();
|
||||
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
|
||||
.unwrap_or_else(|| PathBuf::from(".claude"));
|
||||
Self { cwd, config_home }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn discover(&self) -> Vec<ConfigEntry> {
|
||||
vec![
|
||||
ConfigEntry {
|
||||
source: ConfigSource::User,
|
||||
path: self.config_home.join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude").join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Local,
|
||||
path: self.cwd.join(".claude").join("settings.local.json"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
let Some(value) = read_optional_json_object(&entry.path)? else {
|
||||
continue;
|
||||
};
|
||||
deep_merge_objects(&mut merged, &value);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
merged,
|
||||
loaded_entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
merged: BTreeMap::new(),
|
||||
loaded_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
|
||||
&self.merged
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn loaded_entries(&self) -> &[ConfigEntry] {
|
||||
&self.loaded_entries
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &str) -> Option<&JsonValue> {
|
||||
self.merged.get(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.merged.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_optional_json_object(
|
||||
path: &Path,
|
||||
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(ConfigError::Io(error)),
|
||||
};
|
||||
|
||||
if contents.trim().is_empty() {
|
||||
return Ok(Some(BTreeMap::new()));
|
||||
}
|
||||
|
||||
let parsed = JsonValue::parse(&contents)
|
||||
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
|
||||
let object = parsed.as_object().ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{}: top-level settings value must be a JSON object",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(Some(object.clone()))
|
||||
}
|
||||
|
||||
fn deep_merge_objects(
|
||||
target: &mut BTreeMap<String, JsonValue>,
|
||||
source: &BTreeMap<String, JsonValue>,
|
||||
) {
|
||||
for (key, value) in source {
|
||||
match (target.get_mut(key), value) {
|
||||
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
|
||||
deep_merge_objects(existing, incoming);
|
||||
}
|
||||
_ => {
|
||||
target.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConfigLoader, ConfigSource, CLAUDE_CODE_SETTINGS_SCHEMA_NAME};
|
||||
use crate::json::JsonValue;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("top-level settings value must be a JSON object"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
|
||||
)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
||||
assert_eq!(loaded.loaded_entries().len(), 3);
|
||||
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
||||
assert_eq!(
|
||||
loaded.get("model"),
|
||||
Some(&JsonValue::String("opus".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.get("env")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("env object")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PreToolUse"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use crate::compact::{
|
||||
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
|
||||
};
|
||||
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
use crate::usage::{TokenUsage, UsageTracker};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiRequest {
|
||||
@@ -18,6 +22,7 @@ pub enum AssistantEvent {
|
||||
name: String,
|
||||
input: String,
|
||||
},
|
||||
Usage(TokenUsage),
|
||||
MessageStop,
|
||||
}
|
||||
|
||||
@@ -78,6 +83,7 @@ pub struct TurnSummary {
|
||||
pub assistant_messages: Vec<ConversationMessage>,
|
||||
pub tool_results: Vec<ConversationMessage>,
|
||||
pub iterations: usize,
|
||||
pub usage: TokenUsage,
|
||||
}
|
||||
|
||||
pub struct ConversationRuntime<C, T> {
|
||||
@@ -87,6 +93,7 @@ pub struct ConversationRuntime<C, T> {
|
||||
permission_policy: PermissionPolicy,
|
||||
system_prompt: Vec<String>,
|
||||
max_iterations: usize,
|
||||
usage_tracker: UsageTracker,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
@@ -102,6 +109,7 @@ where
|
||||
permission_policy: PermissionPolicy,
|
||||
system_prompt: Vec<String>,
|
||||
) -> Self {
|
||||
let usage_tracker = UsageTracker::from_session(&session);
|
||||
Self {
|
||||
session,
|
||||
api_client,
|
||||
@@ -109,6 +117,7 @@ where
|
||||
permission_policy,
|
||||
system_prompt,
|
||||
max_iterations: 16,
|
||||
usage_tracker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +153,10 @@ where
|
||||
messages: self.session.messages.clone(),
|
||||
};
|
||||
let events = self.api_client.stream(request)?;
|
||||
let assistant_message = build_assistant_message(events)?;
|
||||
let (assistant_message, usage) = build_assistant_message(events)?;
|
||||
if let Some(usage) = usage {
|
||||
self.usage_tracker.record(usage);
|
||||
}
|
||||
let pending_tool_uses = assistant_message
|
||||
.blocks
|
||||
.iter()
|
||||
@@ -201,9 +213,25 @@ where
|
||||
assistant_messages,
|
||||
tool_results,
|
||||
iterations,
|
||||
usage: self.usage_tracker.cumulative_usage(),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn compact(&self, config: CompactionConfig) -> CompactionResult {
|
||||
compact_session(&self.session, config)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimated_tokens(&self) -> usize {
|
||||
estimate_session_tokens(&self.session)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn usage(&self) -> &UsageTracker {
|
||||
&self.usage_tracker
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.session
|
||||
@@ -217,10 +245,11 @@ where
|
||||
|
||||
fn build_assistant_message(
|
||||
events: Vec<AssistantEvent>,
|
||||
) -> Result<ConversationMessage, RuntimeError> {
|
||||
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
|
||||
let mut text = String::new();
|
||||
let mut blocks = Vec::new();
|
||||
let mut finished = false;
|
||||
let mut usage = None;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
@@ -229,6 +258,7 @@ fn build_assistant_message(
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||
}
|
||||
AssistantEvent::Usage(value) => usage = Some(value),
|
||||
AssistantEvent::MessageStop => {
|
||||
finished = true;
|
||||
}
|
||||
@@ -246,7 +276,10 @@ fn build_assistant_message(
|
||||
return Err(RuntimeError::new("assistant stream produced no content"));
|
||||
}
|
||||
|
||||
Ok(ConversationMessage::assistant(blocks))
|
||||
Ok((
|
||||
ConversationMessage::assistant_with_usage(blocks, usage),
|
||||
usage,
|
||||
))
|
||||
}
|
||||
|
||||
fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
|
||||
@@ -295,12 +328,15 @@ mod tests {
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
|
||||
StaticToolExecutor,
|
||||
};
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::permissions::{
|
||||
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
|
||||
PermissionRequest,
|
||||
};
|
||||
use crate::prompt::SystemPromptBuilder;
|
||||
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
||||
use crate::session::{ContentBlock, MessageRole, Session};
|
||||
use crate::usage::TokenUsage;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct ScriptedApiClient {
|
||||
call_count: usize,
|
||||
@@ -322,6 +358,12 @@ mod tests {
|
||||
name: "add".to_string(),
|
||||
input: "2,2".to_string(),
|
||||
},
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens: 20,
|
||||
output_tokens: 6,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 2,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
@@ -333,6 +375,12 @@ mod tests {
|
||||
assert_eq!(last_message.role, MessageRole::Tool);
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("The answer is 4.".to_string()),
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens: 24,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 3,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
@@ -351,7 +399,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_user_to_tool_to_result_loop_end_to_end() {
|
||||
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
||||
let api_client = ScriptedApiClient { call_count: 0 };
|
||||
let tool_executor = StaticToolExecutor::new().register("add", |input| {
|
||||
let total = input
|
||||
@@ -362,9 +410,13 @@ mod tests {
|
||||
});
|
||||
let permission_policy = PermissionPolicy::new(PermissionMode::Prompt);
|
||||
let system_prompt = SystemPromptBuilder::new()
|
||||
.with_cwd("/tmp/project")
|
||||
.with_project_context(ProjectContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
current_date: "2026-03-31".to_string(),
|
||||
git_status: None,
|
||||
instruction_files: Vec::new(),
|
||||
})
|
||||
.with_os("linux", "6.8")
|
||||
.with_date("2026-03-31")
|
||||
.build();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
@@ -382,6 +434,7 @@ mod tests {
|
||||
assert_eq!(summary.assistant_messages.len(), 2);
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
assert_eq!(runtime.session().messages.len(), 4);
|
||||
assert_eq!(summary.usage.output_tokens, 10);
|
||||
assert!(matches!(
|
||||
runtime.session().messages[1].blocks[1],
|
||||
ContentBlock::ToolUse { .. }
|
||||
@@ -448,4 +501,83 @@ mod tests {
|
||||
ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_usage_tracker_from_restored_session() {
|
||||
struct SimpleApi;
|
||||
impl ApiClient for SimpleApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.messages
|
||||
.push(crate::session::ConversationMessage::assistant_with_usage(
|
||||
vec![ContentBlock::Text {
|
||||
text: "earlier".to_string(),
|
||||
}],
|
||||
Some(TokenUsage {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
cache_creation_input_tokens: 2,
|
||||
cache_read_input_tokens: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
let runtime = ConversationRuntime::new(
|
||||
session,
|
||||
SimpleApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::Allow),
|
||||
vec!["system".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(runtime.usage().turns(), 1);
|
||||
assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_session_after_turns() {
|
||||
struct SimpleApi;
|
||||
impl ApiClient for SimpleApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
SimpleApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::Allow),
|
||||
vec!["system".to_string()],
|
||||
);
|
||||
runtime.run_turn("a", None).expect("turn a");
|
||||
runtime.run_turn("b", None).expect("turn b");
|
||||
runtime.run_turn("c", None).expect("turn c");
|
||||
|
||||
let result = runtime.compact(CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
});
|
||||
assert!(result.summary.contains("Conversation summary"));
|
||||
assert_eq!(
|
||||
result.compacted_session.messages[0].role,
|
||||
MessageRole::System
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
mod bootstrap;
|
||||
mod compact;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod json;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod session;
|
||||
mod usage;
|
||||
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use compact::{
|
||||
compact_session, estimate_session_tokens, format_compact_summary,
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, RuntimeConfig,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use conversation::{
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||
ToolError, ToolExecutor, TurnSummary,
|
||||
@@ -15,6 +26,8 @@ pub use permissions::{
|
||||
PermissionPrompter, PermissionRequest,
|
||||
};
|
||||
pub use prompt::{
|
||||
prepend_bullets, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
||||
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
||||
pub use usage::{TokenUsage, UsageTracker};
|
||||
|
||||
@@ -1,15 +1,89 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptBuildError {
|
||||
Io(std::io::Error),
|
||||
Config(ConfigError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PromptBuildError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Config(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PromptBuildError {}
|
||||
|
||||
impl From<std::io::Error> for PromptBuildError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigError> for PromptBuildError {
|
||||
fn from(value: ConfigError) -> Self {
|
||||
Self::Config(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextFile {
|
||||
pub path: PathBuf,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProjectContext {
|
||||
pub cwd: PathBuf,
|
||||
pub current_date: String,
|
||||
pub git_status: Option<String>,
|
||||
pub instruction_files: Vec<ContextFile>,
|
||||
}
|
||||
|
||||
impl ProjectContext {
|
||||
pub fn discover(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
let cwd = cwd.into();
|
||||
let instruction_files = discover_instruction_files(&cwd)?;
|
||||
Ok(Self {
|
||||
cwd,
|
||||
current_date: current_date.into(),
|
||||
git_status: None,
|
||||
instruction_files,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn discover_with_git(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut context = Self::discover(cwd, current_date)?;
|
||||
context.git_status = read_git_status(&context.cwd);
|
||||
Ok(context)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SystemPromptBuilder {
|
||||
output_style_name: Option<String>,
|
||||
output_style_prompt: Option<String>,
|
||||
cwd: Option<String>,
|
||||
os_name: Option<String>,
|
||||
os_version: Option<String>,
|
||||
date: Option<String>,
|
||||
append_sections: Vec<String>,
|
||||
project_context: Option<ProjectContext>,
|
||||
config: Option<RuntimeConfig>,
|
||||
}
|
||||
|
||||
impl SystemPromptBuilder {
|
||||
@@ -25,12 +99,6 @@ impl SystemPromptBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
|
||||
self.cwd = Some(cwd.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_os(mut self, os_name: impl Into<String>, os_version: impl Into<String>) -> Self {
|
||||
self.os_name = Some(os_name.into());
|
||||
@@ -39,8 +107,14 @@ impl SystemPromptBuilder {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_date(mut self, date: impl Into<String>) -> Self {
|
||||
self.date = Some(date.into());
|
||||
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
||||
self.project_context = Some(project_context);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_runtime_config(mut self, config: RuntimeConfig) -> Self {
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -62,6 +136,15 @@ impl SystemPromptBuilder {
|
||||
sections.push(get_actions_section());
|
||||
sections.push(SYSTEM_PROMPT_DYNAMIC_BOUNDARY.to_string());
|
||||
sections.push(self.environment_section());
|
||||
if let Some(project_context) = &self.project_context {
|
||||
sections.push(render_project_context(project_context));
|
||||
if !project_context.instruction_files.is_empty() {
|
||||
sections.push(render_instruction_files(&project_context.instruction_files));
|
||||
}
|
||||
}
|
||||
if let Some(config) = &self.config {
|
||||
sections.push(render_config_section(config));
|
||||
}
|
||||
sections.extend(self.append_sections.iter().cloned());
|
||||
sections
|
||||
}
|
||||
@@ -72,14 +155,19 @@ impl SystemPromptBuilder {
|
||||
}
|
||||
|
||||
fn environment_section(&self) -> String {
|
||||
let cwd = self.project_context.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|context| context.cwd.display().to_string(),
|
||||
);
|
||||
let date = self.project_context.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|context| context.current_date.clone(),
|
||||
);
|
||||
let mut lines = vec!["# Environment context".to_string()];
|
||||
lines.extend(prepend_bullets(vec![
|
||||
format!("Model family: {FRONTIER_MODEL_NAME}"),
|
||||
format!(
|
||||
"Working directory: {}",
|
||||
self.cwd.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
format!("Date: {}", self.date.as_deref().unwrap_or("unknown")),
|
||||
format!("Working directory: {cwd}"),
|
||||
format!("Date: {date}"),
|
||||
format!(
|
||||
"Platform: {} {}",
|
||||
self.os_name.as_deref().unwrap_or("unknown"),
|
||||
@@ -95,6 +183,118 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||
}
|
||||
|
||||
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
cursor = dir.parent();
|
||||
}
|
||||
directories.reverse();
|
||||
|
||||
let mut files = Vec::new();
|
||||
for dir in directories {
|
||||
for candidate in [
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claude").join("CLAUDE.md"),
|
||||
] {
|
||||
push_context_file(&mut files, candidate)?;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) if !content.trim().is_empty() => {
|
||||
files.push(ContextFile { path, content });
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_git_status(cwd: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
let mut lines = vec!["# Project context".to_string()];
|
||||
lines.extend(prepend_bullets(vec![format!(
|
||||
"Today's date is {}.",
|
||||
project_context.current_date
|
||||
)]));
|
||||
if let Some(status) = &project_context.git_status {
|
||||
lines.push(String::new());
|
||||
lines.push("Git status snapshot:".to_string());
|
||||
lines.push(status.clone());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||
let mut sections = vec!["# Claude instructions".to_string()];
|
||||
for file in files {
|
||||
sections.push(format!("## {}", file.path.display()));
|
||||
sections.push(file.content.trim().to_string());
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
pub fn load_system_prompt(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
os_name: impl Into<String>,
|
||||
os_version: impl Into<String>,
|
||||
) -> Result<Vec<String>, PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||
Ok(SystemPromptBuilder::new()
|
||||
.with_os(os_name, os_version)
|
||||
.with_project_context(project_context)
|
||||
.with_runtime_config(config)
|
||||
.build())
|
||||
}
|
||||
|
||||
fn render_config_section(config: &RuntimeConfig) -> String {
|
||||
let mut lines = vec!["# Runtime config".to_string()];
|
||||
if config.loaded_entries().is_empty() {
|
||||
lines.extend(prepend_bullets(vec![
|
||||
"No Claude Code settings files loaded.".to_string(),
|
||||
]));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.extend(prepend_bullets(
|
||||
config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.map(|entry| format!("Loaded {:?}: {}", entry.source, entry.path.display()))
|
||||
.collect(),
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push(config.as_json().render());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn get_simple_intro_section(has_output_style: bool) -> String {
|
||||
format!(
|
||||
"You are an interactive agent that helps users {} Use the instructions below and the tools available to you to assist the user.\n\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.",
|
||||
@@ -148,22 +348,132 @@ fn get_actions_section() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY};
|
||||
use super::{ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_claude_code_style_sections() {
|
||||
fn discovers_instruction_files_from_ancestor_chain() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
.expect("write local instructions");
|
||||
fs::create_dir_all(root.join("apps")).expect("apps dir");
|
||||
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
|
||||
.expect("write apps instructions");
|
||||
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
|
||||
.expect("write nested rules");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let contents = context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(|file| file.content.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
contents,
|
||||
vec![
|
||||
"root instructions",
|
||||
"local instructions",
|
||||
"apps instructions",
|
||||
"nested rules"
|
||||
]
|
||||
);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_with_git_includes_status_snapshot() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
std::process::Command::new("git")
|
||||
.args(["init", "--quiet"])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.expect("git init should run");
|
||||
fs::write(root.join("CLAUDE.md"), "rules").expect("write instructions");
|
||||
fs::write(root.join("tracked.txt"), "hello").expect("write tracked file");
|
||||
|
||||
let context =
|
||||
ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
|
||||
|
||||
let status = context.git_status.expect("git status should be present");
|
||||
assert!(status.contains("## No commits yet on") || status.contains("## "));
|
||||
assert!(status.contains("?? CLAUDE.md"));
|
||||
assert!(status.contains("?? tracked.txt"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_system_prompt_reads_claude_files_and_config() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write instructions");
|
||||
fs::write(
|
||||
root.join(".claude").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("change cwd");
|
||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
||||
.expect("system prompt should load")
|
||||
.join(
|
||||
"
|
||||
|
||||
",
|
||||
);
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_claude_code_style_sections_with_project_context() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write CLAUDE.md");
|
||||
fs::write(
|
||||
root.join(".claude").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let project_context =
|
||||
ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let config = ConfigLoader::new(&root, root.join("missing-home"))
|
||||
.load()
|
||||
.expect("config should load");
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_output_style("Concise", "Prefer short answers.")
|
||||
.with_cwd("/tmp/project")
|
||||
.with_os("linux", "6.8")
|
||||
.with_date("2026-03-31")
|
||||
.append_section("# Custom\nExtra")
|
||||
.with_project_context(project_context)
|
||||
.with_runtime_config(config)
|
||||
.render();
|
||||
|
||||
assert!(prompt.contains("# System"));
|
||||
assert!(prompt.contains("# Doing tasks"));
|
||||
assert!(prompt.contains("# Executing actions with care"));
|
||||
assert!(prompt.contains("# Project context"));
|
||||
assert!(prompt.contains("# Claude instructions"));
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
||||
assert!(prompt.contains("Working directory: /tmp/project"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::json::{JsonError, JsonValue};
|
||||
use crate::usage::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageRole {
|
||||
@@ -35,6 +36,7 @@ pub enum ContentBlock {
|
||||
pub struct ConversationMessage {
|
||||
pub role: MessageRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -145,6 +147,7 @@ impl ConversationMessage {
|
||||
Self {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text { text: text.into() }],
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +156,16 @@ impl ConversationMessage {
|
||||
Self {
|
||||
role: MessageRole::Assistant,
|
||||
blocks,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn assistant_with_usage(blocks: Vec<ContentBlock>, usage: Option<TokenUsage>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::Assistant,
|
||||
blocks,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +184,7 @@ impl ConversationMessage {
|
||||
output: output.into(),
|
||||
is_error,
|
||||
}],
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +207,9 @@ impl ConversationMessage {
|
||||
"blocks".to_string(),
|
||||
JsonValue::Array(self.blocks.iter().map(ContentBlock::to_json).collect()),
|
||||
);
|
||||
if let Some(usage) = self.usage {
|
||||
object.insert("usage".to_string(), usage_to_json(usage));
|
||||
}
|
||||
JsonValue::Object(object)
|
||||
}
|
||||
|
||||
@@ -222,7 +239,12 @@ impl ConversationMessage {
|
||||
.iter()
|
||||
.map(ContentBlock::from_json)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { role, blocks })
|
||||
let usage = object.get("usage").map(usage_from_json).transpose()?;
|
||||
Ok(Self {
|
||||
role,
|
||||
blocks,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +324,39 @@ impl ContentBlock {
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_to_json(usage: TokenUsage) -> JsonValue {
|
||||
let mut object = BTreeMap::new();
|
||||
object.insert(
|
||||
"input_tokens".to_string(),
|
||||
JsonValue::Number(i64::from(usage.input_tokens)),
|
||||
);
|
||||
object.insert(
|
||||
"output_tokens".to_string(),
|
||||
JsonValue::Number(i64::from(usage.output_tokens)),
|
||||
);
|
||||
object.insert(
|
||||
"cache_creation_input_tokens".to_string(),
|
||||
JsonValue::Number(i64::from(usage.cache_creation_input_tokens)),
|
||||
);
|
||||
object.insert(
|
||||
"cache_read_input_tokens".to_string(),
|
||||
JsonValue::Number(i64::from(usage.cache_read_input_tokens)),
|
||||
);
|
||||
JsonValue::Object(object)
|
||||
}
|
||||
|
||||
fn usage_from_json(value: &JsonValue) -> Result<TokenUsage, SessionError> {
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| SessionError::Format("usage must be an object".to_string()))?;
|
||||
Ok(TokenUsage {
|
||||
input_tokens: required_u32(object, "input_tokens")?,
|
||||
output_tokens: required_u32(object, "output_tokens")?,
|
||||
cache_creation_input_tokens: required_u32(object, "cache_creation_input_tokens")?,
|
||||
cache_read_input_tokens: required_u32(object, "cache_read_input_tokens")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_string(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
@@ -313,9 +368,18 @@ fn required_string(
|
||||
.ok_or_else(|| SessionError::Format(format!("missing {key}")))
|
||||
}
|
||||
|
||||
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_i64)
|
||||
.ok_or_else(|| SessionError::Format(format!("missing {key}")))?;
|
||||
u32::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use crate::usage::TokenUsage;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -325,16 +389,26 @@ mod tests {
|
||||
session
|
||||
.messages
|
||||
.push(ConversationMessage::user_text("hello"));
|
||||
session.messages.push(ConversationMessage::assistant(vec![
|
||||
ContentBlock::Text {
|
||||
text: "thinking".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "bash".to_string(),
|
||||
input: "echo hi".to_string(),
|
||||
},
|
||||
]));
|
||||
session
|
||||
.messages
|
||||
.push(ConversationMessage::assistant_with_usage(
|
||||
vec![
|
||||
ContentBlock::Text {
|
||||
text: "thinking".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "bash".to_string(),
|
||||
input: "echo hi".to_string(),
|
||||
},
|
||||
],
|
||||
Some(TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 2,
|
||||
}),
|
||||
));
|
||||
session.messages.push(ConversationMessage::tool_result(
|
||||
"tool-1", "bash", "hi", false,
|
||||
));
|
||||
@@ -350,5 +424,9 @@ mod tests {
|
||||
|
||||
assert_eq!(restored, session);
|
||||
assert_eq!(restored.messages[2].role, MessageRole::Tool);
|
||||
assert_eq!(
|
||||
restored.messages[1].usage.expect("usage").total_tokens(),
|
||||
17
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
121
rust/crates/runtime/src/usage.rs
Normal file
121
rust/crates/runtime/src/usage.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub cache_creation_input_tokens: u32,
|
||||
pub cache_read_input_tokens: u32,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
#[must_use]
|
||||
pub fn total_tokens(self) -> u32 {
|
||||
self.input_tokens
|
||||
+ self.output_tokens
|
||||
+ self.cache_creation_input_tokens
|
||||
+ self.cache_read_input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UsageTracker {
|
||||
latest_turn: TokenUsage,
|
||||
cumulative: TokenUsage,
|
||||
turns: u32,
|
||||
}
|
||||
|
||||
impl UsageTracker {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_session(session: &Session) -> Self {
|
||||
let mut tracker = Self::new();
|
||||
for message in &session.messages {
|
||||
if let Some(usage) = message.usage {
|
||||
tracker.record(usage);
|
||||
}
|
||||
}
|
||||
tracker
|
||||
}
|
||||
|
||||
pub fn record(&mut self, usage: TokenUsage) {
|
||||
self.latest_turn = usage;
|
||||
self.cumulative.input_tokens += usage.input_tokens;
|
||||
self.cumulative.output_tokens += usage.output_tokens;
|
||||
self.cumulative.cache_creation_input_tokens += usage.cache_creation_input_tokens;
|
||||
self.cumulative.cache_read_input_tokens += usage.cache_read_input_tokens;
|
||||
self.turns += 1;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current_turn_usage(&self) -> TokenUsage {
|
||||
self.latest_turn
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cumulative_usage(&self) -> TokenUsage {
|
||||
self.cumulative
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn turns(&self) -> u32 {
|
||||
self.turns
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TokenUsage, UsageTracker};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn tracks_true_cumulative_usage() {
|
||||
let mut tracker = UsageTracker::new();
|
||||
tracker.record(TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 2,
|
||||
cache_read_input_tokens: 1,
|
||||
});
|
||||
tracker.record(TokenUsage {
|
||||
input_tokens: 20,
|
||||
output_tokens: 6,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 2,
|
||||
});
|
||||
|
||||
assert_eq!(tracker.turns(), 2);
|
||||
assert_eq!(tracker.current_turn_usage().input_tokens, 20);
|
||||
assert_eq!(tracker.current_turn_usage().output_tokens, 6);
|
||||
assert_eq!(tracker.cumulative_usage().output_tokens, 10);
|
||||
assert_eq!(tracker.cumulative_usage().input_tokens, 30);
|
||||
assert_eq!(tracker.cumulative_usage().total_tokens(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_usage_from_session_messages() {
|
||||
let session = Session {
|
||||
version: 1,
|
||||
messages: vec![ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
|
||||
let tracker = UsageTracker::from_session(&session);
|
||||
assert_eq!(tracker.turns(), 1);
|
||||
assert_eq!(tracker.cumulative_usage().total_tokens(), 8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user