4 Commits

Author SHA1 Message Date
Yeachan-Heo
799c92eada feat: cache-tracking progress 2026-04-01 06:25:26 +00:00
Yeachan-Heo
c9d214c8d1 feat: cache-tracking progress 2026-04-01 06:15:13 +00:00
Yeachan-Heo
26344c578b wip: cache-tracking progress 2026-04-01 04:40:17 +00:00
Yeachan-Heo
0cf2204d43 wip: cache-tracking progress 2026-04-01 04:30:24 +00:00
33 changed files with 1488 additions and 9012 deletions

12
rust/Cargo.lock generated
View File

@@ -111,7 +111,6 @@ dependencies = [
name = "commands"
version = "0.1.0"
dependencies = [
"plugins",
"runtime",
]
@@ -826,14 +825,6 @@ dependencies = [
"time",
]
[[package]]
name = "plugins"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1101,7 +1092,6 @@ name = "runtime"
version = "0.1.0"
dependencies = [
"glob",
"plugins",
"regex",
"serde",
"serde_json",
@@ -1191,7 +1181,6 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
@@ -1557,7 +1546,6 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"plugins",
"reqwest",
"runtime",
"serde",

View File

@@ -1,6 +1,6 @@
# 🦞 Claw Code — Rust Implementation
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
A high-performance Rust rewrite of the Claude Code CLI agent harness. Built for speed, safety, and native tool execution.
## Quick Start

View File

@@ -1,4 +1,5 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use runtime::{
@@ -8,8 +9,9 @@ use runtime::{
use serde::Deserialize;
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use crate::sse::SseParser;
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
use crate::types::{MessageRequest, MessageResponse, StreamEvent, Usage};
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const ANTHROPIC_VERSION: &str = "2023-06-01";
@@ -108,6 +110,8 @@ pub struct AnthropicClient {
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
prompt_cache: Option<PromptCache>,
last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>,
}
impl AnthropicClient {
@@ -120,6 +124,8 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)),
}
}
@@ -132,6 +138,8 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)),
}
}
@@ -189,6 +197,30 @@ impl AnthropicClient {
self
}
#[must_use]
pub fn with_prompt_cache(mut self, prompt_cache: PromptCache) -> Self {
self.prompt_cache = Some(prompt_cache);
self
}
#[must_use]
pub fn prompt_cache(&self) -> Option<&PromptCache> {
self.prompt_cache.as_ref()
}
#[must_use]
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
self.prompt_cache.as_ref().map(PromptCache::stats)
}
#[must_use]
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
self.last_prompt_cache_record()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
#[must_use]
pub fn auth_source(&self) -> &AuthSource {
&self.auth
@@ -198,10 +230,19 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
self.store_last_prompt_cache_record(None);
let request = MessageRequest {
stream: false,
..request.clone()
};
if let Some(prompt_cache) = &self.prompt_cache {
if let Some(response) = prompt_cache.lookup_completion(&request) {
self.store_last_prompt_cache_record(Some(prompt_cache_record_from_stats(
prompt_cache.stats(),
)));
return Ok(response);
}
}
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
@@ -211,6 +252,10 @@ impl AnthropicClient {
if response.request_id.is_none() {
response.request_id = request_id;
}
if let Some(prompt_cache) = &self.prompt_cache {
let record = prompt_cache.record_response(&request, &response);
self.store_last_prompt_cache_record(Some(record));
}
Ok(response)
}
@@ -218,6 +263,7 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
self.store_last_prompt_cache_record(None);
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
@@ -227,9 +273,30 @@ impl AnthropicClient {
parser: SseParser::new(),
pending: VecDeque::new(),
done: false,
cache_tracking: self
.prompt_cache
.as_ref()
.map(|prompt_cache| StreamCacheTracking {
prompt_cache: prompt_cache.clone(),
request: request.clone().with_streaming(),
last_usage: None,
finalized: false,
last_record: self.last_prompt_cache_record.clone(),
}),
})
}
fn store_last_prompt_cache_record(&self, record: Option<PromptCacheRecord>) {
*self
.last_prompt_cache_record()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = record;
}
fn last_prompt_cache_record(&self) -> &Arc<Mutex<Option<PromptCacheRecord>>> {
&self.last_prompt_cache_record
}
pub async fn exchange_oauth_code(
&self,
config: &OAuthConfig,
@@ -527,6 +594,7 @@ pub struct MessageStream {
parser: SseParser,
pending: VecDeque<StreamEvent>,
done: bool,
cache_tracking: Option<StreamCacheTracking>,
}
impl MessageStream {
@@ -538,6 +606,9 @@ impl MessageStream {
pub async fn next_event(&mut self) -> Result<Option<StreamEvent>, ApiError> {
loop {
if let Some(event) = self.pending.pop_front() {
if let Some(cache_tracking) = &mut self.cache_tracking {
cache_tracking.observe(&event);
}
return Ok(Some(event));
}
@@ -545,8 +616,14 @@ impl MessageStream {
let remaining = self.parser.finish()?;
self.pending.extend(remaining);
if let Some(event) = self.pending.pop_front() {
if let Some(cache_tracking) = &mut self.cache_tracking {
cache_tracking.observe(&event);
}
return Ok(Some(event));
}
if let Some(cache_tracking) = &mut self.cache_tracking {
cache_tracking.finalize();
}
return Ok(None);
}
@@ -562,6 +639,53 @@ impl MessageStream {
}
}
#[derive(Debug, Clone)]
struct StreamCacheTracking {
prompt_cache: PromptCache,
request: MessageRequest,
last_usage: Option<Usage>,
finalized: bool,
last_record: Arc<Mutex<Option<PromptCacheRecord>>>,
}
impl StreamCacheTracking {
fn observe(&mut self, event: &StreamEvent) {
match event {
StreamEvent::MessageStart(event) => {
self.last_usage = Some(event.message.usage.clone());
}
StreamEvent::MessageDelta(event) => {
self.last_usage = Some(event.usage.clone());
}
StreamEvent::ContentBlockStart(_)
| StreamEvent::ContentBlockDelta(_)
| StreamEvent::ContentBlockStop(_)
| StreamEvent::MessageStop(_) => {}
}
}
fn finalize(&mut self) {
if self.finalized {
return;
}
if let Some(usage) = &self.last_usage {
let record = self.prompt_cache.record_usage(&self.request, usage);
*self
.last_record
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
}
self.finalized = true;
}
}
fn prompt_cache_record_from_stats(stats: PromptCacheStats) -> PromptCacheRecord {
PromptCacheRecord {
cache_break: None,
stats,
}
}
async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
let status = response.status();
if status.is_success() {
@@ -606,7 +730,7 @@ mod tests {
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -616,19 +740,15 @@ mod tests {
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
};
use crate::test_env_lock;
use crate::types::{ContentBlockDelta, MessageRequest};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock")
}
fn temp_config_home() -> std::path::PathBuf {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"api-oauth-test-{}-{}",
"api-oauth-test-{}-{}-{}",
std::process::id(),
NEXT_ID.fetch_add(1, Ordering::Relaxed),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
@@ -668,7 +788,7 @@ mod tests {
#[test]
fn read_api_key_requires_presence() {
let _guard = env_lock();
let _guard = test_env_lock();
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("CLAUDE_CONFIG_HOME");
@@ -678,7 +798,7 @@ mod tests {
#[test]
fn read_api_key_requires_non_empty_value() {
let _guard = env_lock();
let _guard = test_env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("empty key should error");
@@ -688,7 +808,7 @@ mod tests {
#[test]
fn read_api_key_prefers_api_key_env() {
let _guard = env_lock();
let _guard = test_env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
assert_eq!(
@@ -701,7 +821,7 @@ mod tests {
#[test]
fn read_auth_token_reads_auth_token_env() {
let _guard = env_lock();
let _guard = test_env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
@@ -721,7 +841,7 @@ mod tests {
#[test]
fn auth_source_from_env_combines_api_key_and_bearer_token() {
let _guard = env_lock();
let _guard = test_env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
let auth = AuthSource::from_env().expect("env auth");
@@ -733,7 +853,7 @@ mod tests {
#[test]
fn auth_source_from_saved_oauth_when_env_absent() {
let _guard = env_lock();
let _guard = test_env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
@@ -772,7 +892,7 @@ mod tests {
#[test]
fn resolve_saved_oauth_token_refreshes_expired_credentials() {
let _guard = env_lock();
let _guard = test_env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
@@ -804,7 +924,7 @@ mod tests {
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
let _guard = env_lock();
let _guard = test_env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
@@ -828,7 +948,7 @@ mod tests {
#[test]
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let _guard = test_env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
@@ -860,7 +980,7 @@ mod tests {
#[test]
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
let _guard = env_lock();
let _guard = test_env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");

View File

@@ -1,5 +1,6 @@
mod client;
mod error;
mod prompt_cache;
mod sse;
mod types;
@@ -8,6 +9,10 @@ pub use client::{
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
};
pub use error::ApiError;
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
PromptCacheStats,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
@@ -15,3 +20,11 @@ pub use types::{
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

View File

@@ -0,0 +1,727 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::types::{MessageRequest, MessageResponse, Usage};
const DEFAULT_COMPLETION_TTL_SECS: u64 = 30;
const DEFAULT_PROMPT_TTL_SECS: u64 = 5 * 60;
const DEFAULT_BREAK_MIN_DROP: u32 = 2_000;
const MAX_SANITIZED_LENGTH: usize = 80;
const REQUEST_FINGERPRINT_VERSION: u32 = 1;
const REQUEST_FINGERPRINT_PREFIX: &str = "v1";
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone)]
pub struct PromptCacheConfig {
pub session_id: String,
pub completion_ttl: Duration,
pub prompt_ttl: Duration,
pub cache_break_min_drop: u32,
}
impl PromptCacheConfig {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
completion_ttl: Duration::from_secs(DEFAULT_COMPLETION_TTL_SECS),
prompt_ttl: Duration::from_secs(DEFAULT_PROMPT_TTL_SECS),
cache_break_min_drop: DEFAULT_BREAK_MIN_DROP,
}
}
}
impl Default for PromptCacheConfig {
fn default() -> Self {
Self::new("default")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCachePaths {
pub root: PathBuf,
pub session_dir: PathBuf,
pub completion_dir: PathBuf,
pub session_state_path: PathBuf,
pub stats_path: PathBuf,
}
impl PromptCachePaths {
#[must_use]
pub fn for_session(session_id: &str) -> Self {
let root = base_cache_root();
let session_dir = root.join(sanitize_path_segment(session_id));
let completion_dir = session_dir.join("completions");
Self {
root,
session_state_path: session_dir.join("session-state.json"),
stats_path: session_dir.join("stats.json"),
session_dir,
completion_dir,
}
}
#[must_use]
pub fn completion_entry_path(&self, request_hash: &str) -> PathBuf {
self.completion_dir.join(format!("{request_hash}.json"))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCacheStats {
pub tracked_requests: u64,
pub completion_cache_hits: u64,
pub completion_cache_misses: u64,
pub completion_cache_writes: u64,
pub expected_invalidations: u64,
pub unexpected_cache_breaks: u64,
pub total_cache_creation_input_tokens: u64,
pub total_cache_read_input_tokens: u64,
pub last_cache_creation_input_tokens: Option<u32>,
pub last_cache_read_input_tokens: Option<u32>,
pub last_request_hash: Option<String>,
pub last_completion_cache_key: Option<String>,
pub last_break_reason: Option<String>,
pub last_cache_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheBreakEvent {
pub unexpected: bool,
pub reason: String,
pub previous_cache_read_input_tokens: u32,
pub current_cache_read_input_tokens: u32,
pub token_drop: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptCacheRecord {
pub cache_break: Option<CacheBreakEvent>,
pub stats: PromptCacheStats,
}
#[derive(Debug, Clone)]
pub struct PromptCache {
inner: Arc<Mutex<PromptCacheInner>>,
}
impl PromptCache {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self::with_config(PromptCacheConfig::new(session_id))
}
#[must_use]
pub fn with_config(config: PromptCacheConfig) -> Self {
let paths = PromptCachePaths::for_session(&config.session_id);
let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default();
let previous = read_json::<TrackedPromptState>(&paths.session_state_path);
Self {
inner: Arc::new(Mutex::new(PromptCacheInner {
config,
paths,
stats,
previous,
})),
}
}
#[must_use]
pub fn paths(&self) -> PromptCachePaths {
self.lock().paths.clone()
}
#[must_use]
pub fn stats(&self) -> PromptCacheStats {
self.lock().stats.clone()
}
#[must_use]
pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> {
let request_hash = request_hash_hex(request);
let (paths, ttl) = {
let inner = self.lock();
(inner.paths.clone(), inner.config.completion_ttl)
};
let entry_path = paths.completion_entry_path(&request_hash);
let entry = read_json::<CompletionCacheEntry>(&entry_path);
let Some(entry) = entry else {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash);
persist_state(&inner);
return None;
};
if entry.fingerprint_version != current_fingerprint_version() {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash.clone());
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs) >= ttl.as_secs();
let mut inner = self.lock();
inner.stats.last_completion_cache_key = Some(request_hash.clone());
if expired {
inner.stats.completion_cache_misses += 1;
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
inner.stats.completion_cache_hits += 1;
apply_usage_to_stats(
&mut inner.stats,
&entry.response.usage,
&request_hash,
"completion-cache",
);
inner.previous = Some(TrackedPromptState::from_usage(
request,
&entry.response.usage,
));
persist_state(&inner);
Some(entry.response)
}
#[must_use]
pub fn record_response(
&self,
request: &MessageRequest,
response: &MessageResponse,
) -> PromptCacheRecord {
self.record_usage_internal(request, &response.usage, Some(response))
}
#[must_use]
pub fn record_usage(&self, request: &MessageRequest, usage: &Usage) -> PromptCacheRecord {
self.record_usage_internal(request, usage, None)
}
fn record_usage_internal(
&self,
request: &MessageRequest,
usage: &Usage,
response: Option<&MessageResponse>,
) -> PromptCacheRecord {
let request_hash = request_hash_hex(request);
let mut inner = self.lock();
let previous = inner.previous.clone();
let current = TrackedPromptState::from_usage(request, usage);
let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current);
inner.stats.tracked_requests += 1;
apply_usage_to_stats(&mut inner.stats, usage, &request_hash, "api-response");
if let Some(event) = &cache_break {
if event.unexpected {
inner.stats.unexpected_cache_breaks += 1;
} else {
inner.stats.expected_invalidations += 1;
}
inner.stats.last_break_reason = Some(event.reason.clone());
}
inner.previous = Some(current);
if let Some(response) = response {
write_completion_entry(&inner.paths, &request_hash, response);
inner.stats.completion_cache_writes += 1;
}
persist_state(&inner);
PromptCacheRecord {
cache_break,
stats: inner.stats.clone(),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, PromptCacheInner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(Debug)]
struct PromptCacheInner {
config: PromptCacheConfig,
paths: PromptCachePaths,
stats: PromptCacheStats,
previous: Option<TrackedPromptState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CompletionCacheEntry {
cached_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
response: MessageResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TrackedPromptState {
observed_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
model_hash: u64,
system_hash: u64,
tools_hash: u64,
messages_hash: u64,
cache_read_input_tokens: u32,
}
impl TrackedPromptState {
fn from_usage(request: &MessageRequest, usage: &Usage) -> Self {
let hashes = RequestFingerprints::from_request(request);
Self {
observed_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
model_hash: hashes.model,
system_hash: hashes.system,
tools_hash: hashes.tools,
messages_hash: hashes.messages,
cache_read_input_tokens: usage.cache_read_input_tokens,
}
}
}
#[derive(Debug, Clone, Copy)]
struct RequestFingerprints {
model: u64,
system: u64,
tools: u64,
messages: u64,
}
impl RequestFingerprints {
fn from_request(request: &MessageRequest) -> Self {
Self {
model: hash_serializable(&request.model),
system: hash_serializable(&request.system),
tools: hash_serializable(&request.tools),
messages: hash_serializable(&request.messages),
}
}
}
fn detect_cache_break(
config: &PromptCacheConfig,
previous: Option<&TrackedPromptState>,
current: &TrackedPromptState,
) -> Option<CacheBreakEvent> {
let previous = previous?;
if previous.fingerprint_version != current.fingerprint_version {
return Some(CacheBreakEvent {
unexpected: false,
reason: format!(
"fingerprint version changed (v{} -> v{})",
previous.fingerprint_version, current.fingerprint_version
),
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop: previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens),
});
}
let token_drop = previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens);
if token_drop < config.cache_break_min_drop {
return None;
}
let mut reasons = Vec::new();
if previous.model_hash != current.model_hash {
reasons.push("model changed");
}
if previous.system_hash != current.system_hash {
reasons.push("system prompt changed");
}
if previous.tools_hash != current.tools_hash {
reasons.push("tool definitions changed");
}
if previous.messages_hash != current.messages_hash {
reasons.push("message payload changed");
}
let elapsed = current
.observed_at_unix_secs
.saturating_sub(previous.observed_at_unix_secs);
let (unexpected, reason) = if reasons.is_empty() {
if elapsed > config.prompt_ttl.as_secs() {
(
false,
format!("possible prompt cache TTL expiry after {elapsed}s"),
)
} else {
(
true,
"cache read tokens dropped while prompt fingerprint remained stable".to_string(),
)
}
} else {
(false, reasons.join(", "))
};
Some(CacheBreakEvent {
unexpected,
reason,
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop,
})
}
fn apply_usage_to_stats(
stats: &mut PromptCacheStats,
usage: &Usage,
request_hash: &str,
source: &str,
) {
stats.total_cache_creation_input_tokens += u64::from(usage.cache_creation_input_tokens);
stats.total_cache_read_input_tokens += u64::from(usage.cache_read_input_tokens);
stats.last_cache_creation_input_tokens = Some(usage.cache_creation_input_tokens);
stats.last_cache_read_input_tokens = Some(usage.cache_read_input_tokens);
stats.last_request_hash = Some(request_hash.to_string());
stats.last_cache_source = Some(source.to_string());
}
fn persist_state(inner: &PromptCacheInner) {
let _ = ensure_cache_dirs(&inner.paths);
let _ = write_json(&inner.paths.stats_path, &inner.stats);
if let Some(previous) = &inner.previous {
let _ = write_json(&inner.paths.session_state_path, previous);
}
}
fn write_completion_entry(
paths: &PromptCachePaths,
request_hash: &str,
response: &MessageResponse,
) {
let _ = ensure_cache_dirs(paths);
let entry = CompletionCacheEntry {
cached_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
response: response.clone(),
};
let _ = write_json(&paths.completion_entry_path(request_hash), &entry);
}
fn ensure_cache_dirs(paths: &PromptCachePaths) -> std::io::Result<()> {
fs::create_dir_all(&paths.completion_dir)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let json = serde_json::to_vec_pretty(value)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
fs::write(path, json)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn request_hash_hex(request: &MessageRequest) -> String {
format!(
"{REQUEST_FINGERPRINT_PREFIX}-{:016x}",
hash_serializable(request)
)
}
fn hash_serializable<T: Serialize>(value: &T) -> u64 {
let json = serde_json::to_vec(value).unwrap_or_default();
stable_hash_bytes(&json)
}
fn sanitize_path_segment(value: &str) -> String {
let sanitized: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
if sanitized.len() <= MAX_SANITIZED_LENGTH {
return sanitized;
}
let suffix = format!("-{:x}", hash_string(value));
format!(
"{}{}",
&sanitized[..MAX_SANITIZED_LENGTH.saturating_sub(suffix.len())],
suffix
)
}
fn hash_string(value: &str) -> u64 {
stable_hash_bytes(value.as_bytes())
}
fn base_cache_root() -> PathBuf {
if let Some(config_home) = std::env::var_os("CLAUDE_CONFIG_HOME") {
return PathBuf::from(config_home)
.join("cache")
.join("prompt-cache");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".claude")
.join("cache")
.join("prompt-cache");
}
std::env::temp_dir().join("claude-prompt-cache")
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
const fn current_fingerprint_version() -> u32 {
REQUEST_FINGERPRINT_VERSION
}
fn stable_hash_bytes(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
detect_cache_break, read_json, request_hash_hex, sanitize_path_segment, PromptCache,
PromptCacheConfig, PromptCachePaths, TrackedPromptState, REQUEST_FINGERPRINT_PREFIX,
};
use crate::test_env_lock;
use crate::types::{InputMessage, MessageRequest, MessageResponse, OutputContentBlock, Usage};
#[test]
fn path_builder_sanitizes_session_identifier() {
let paths = PromptCachePaths::for_session("session:/with spaces");
let session_dir = paths
.session_dir
.file_name()
.and_then(|value| value.to_str())
.expect("session dir name");
assert_eq!(session_dir, "session--with-spaces");
assert!(paths.completion_dir.ends_with("completions"));
assert!(paths.stats_path.ends_with("stats.json"));
assert!(paths.session_state_path.ends_with("session-state.json"));
}
#[test]
fn request_fingerprint_drives_unexpected_break_detection() {
let request = sample_request("same");
let previous = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(event.unexpected);
assert!(event.reason.contains("stable"));
}
#[test]
fn changed_prompt_marks_break_as_expected() {
let previous_request = sample_request("first");
let current_request = sample_request("second");
let previous = TrackedPromptState::from_usage(
&previous_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&current_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(!event.unexpected);
assert!(event.reason.contains("message payload changed"));
}
#[test]
fn completion_cache_round_trip_persists_recent_response() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("unit-test-session");
let request = sample_request("cache me");
let response = sample_response(42, 12, "cached");
assert!(cache.lookup_completion(&request).is_none());
let record = cache.record_response(&request, &response);
assert!(record.cache_break.is_none());
let cached = cache
.lookup_completion(&request)
.expect("cached response should load");
assert_eq!(cached.content, response.content);
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 1);
assert_eq!(stats.completion_cache_misses, 1);
assert_eq!(stats.completion_cache_writes, 1);
let persisted = read_json::<super::PromptCacheStats>(&cache.paths().stats_path)
.expect("stats should persist");
assert_eq!(persisted.completion_cache_hits, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn distinct_requests_do_not_collide_in_completion_cache() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-distinct-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("distinct-request-session");
let first_request = sample_request("first");
let second_request = sample_request("second");
let response = sample_response(42, 12, "cached");
let _ = cache.record_response(&first_request, &response);
assert!(cache.lookup_completion(&second_request).is_none());
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn expired_completion_entries_are_not_reused() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-expired-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::with_config(PromptCacheConfig {
session_id: "expired-session".to_string(),
completion_ttl: Duration::ZERO,
..PromptCacheConfig::default()
});
let request = sample_request("expire me");
let response = sample_response(7, 3, "stale");
let _ = cache.record_response(&request, &response);
assert!(cache.lookup_completion(&request).is_none());
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 0);
assert_eq!(stats.completion_cache_misses, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn sanitize_path_caps_long_values() {
let long_value = "x".repeat(200);
let sanitized = sanitize_path_segment(&long_value);
assert!(sanitized.len() <= 80);
}
#[test]
fn request_hashes_are_versioned_and_stable() {
let request = sample_request("stable");
let first = request_hash_hex(&request);
let second = request_hash_hex(&request);
assert_eq!(first, second);
assert!(first.starts_with(REQUEST_FINGERPRINT_PREFIX));
}
fn sample_request(text: &str) -> MessageRequest {
MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text(text)],
system: Some("system".to_string()),
tools: None,
tool_choice: None,
stream: false,
}
}
fn sample_response(
cache_read_input_tokens: u32,
output_tokens: u32,
text: &str,
) -> MessageResponse {
MessageResponse {
id: "msg_test".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::Text {
text: text.to_string(),
}],
model: "claude-3-7-sonnet-latest".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 10,
cache_creation_input_tokens: 5,
cache_read_input_tokens,
output_tokens,
},
request_id: Some("req_test".to_string()),
}
}
}

View File

@@ -216,64 +216,4 @@ mod tests {
))
);
}
#[test]
fn parses_thinking_content_block_start() {
let frame = concat!(
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":null}}\n\n"
);
let event = parse_frame(frame).expect("frame should parse");
assert_eq!(
event,
Some(StreamEvent::ContentBlockStart(
crate::types::ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking {
thinking: String::new(),
signature: None,
},
},
))
);
}
#[test]
fn parses_thinking_related_deltas() {
let thinking = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n"
);
let signature = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n"
);
let thinking_event = parse_frame(thinking).expect("thinking delta should parse");
let signature_event = parse_frame(signature).expect("signature delta should parse");
assert_eq!(
thinking_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta {
thinking: "step 1".to_string(),
},
}
))
);
assert_eq!(
signature_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::SignatureDelta {
signature: "sig_123".to_string(),
},
}
))
);
}
}

View File

@@ -135,15 +135,6 @@ pub enum OutputContentBlock {
name: String,
input: Value,
},
Thinking {
#[serde(default)]
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
data: Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -199,8 +190,6 @@ pub struct ContentBlockDeltaEvent {
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -1,17 +1,25 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use std::time::Duration;
use api::{
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
StreamEvent, ToolChoice, ToolDefinition,
PromptCache, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[tokio::test]
async fn send_message_posts_json_and_parses_response() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -45,6 +53,8 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(response.id, "msg_test");
assert_eq!(response.total_tokens(), 16);
assert_eq!(response.request_id.as_deref(), Some("req_body_123"));
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
@@ -76,21 +86,18 @@ async fn send_message_posts_json_and_parses_response() {
}
#[tokio::test]
async fn send_message_parses_response_with_thinking_blocks() {
async fn send_message_parses_prompt_cache_token_usage_from_response() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_thinking\",",
"\"id\":\"msg_cache_tokens\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[",
"{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},",
"{\"type\":\"text\",\"text\":\"Final answer\"}",
"],",
"\"content\":[{\"type\":\"text\",\"text\":\"Cache tokens\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4}",
"\"usage\":{\"input_tokens\":12,\"cache_creation_input_tokens\":321,\"cache_read_input_tokens\":654,\"output_tokens\":4}",
"}"
);
let server = spawn_server(
@@ -105,24 +112,29 @@ async fn send_message_parses_response_with_thinking_blocks() {
.await
.expect("request should succeed");
assert_eq!(response.content.len(), 2);
assert!(matches!(
&response.content[0],
OutputContentBlock::Thinking { thinking, signature }
if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&response.content[1],
OutputContentBlock::Text { text } if text == "Final answer"
));
assert_eq!(response.usage.input_tokens, 12);
assert_eq!(response.usage.cache_creation_input_tokens, 321);
assert_eq!(response.usage.cache_read_input_tokens, 654);
assert_eq!(response.usage.output_tokens, 4);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stream_message_parses_sse_events_with_tool_use() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-stream-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":13,\"cache_read_input_tokens\":21,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_123\",\"name\":\"get_weather\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
@@ -130,7 +142,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":34,\"cache_read_input_tokens\":55,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
@@ -148,7 +160,8 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("stream-session"));
let mut stream = client
.stream_message(&sample_request(false))
.await
@@ -202,85 +215,20 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_parses_sse_events_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.tracked_requests, 1);
assert_eq!(cache_stats.last_cache_creation_input_tokens, Some(34));
assert_eq!(cache_stats.last_cache_read_input_tokens, Some(55));
assert_eq!(
cache_stats.last_cache_source.as_deref(),
Some("api-response")
);
let server = spawn_server(
state,
vec![http_response("200 OK", "text/event-stream", sse)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
let mut events = Vec::new();
while let Some(event) = stream
.next_event()
.await
.expect("stream event should parse")
{
events.push(event);
}
assert_eq!(events.len(), 9);
assert!(matches!(
&events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Thinking { thinking, signature },
..
}) if thinking.is_empty() && signature.is_none()
));
assert!(matches!(
&events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::ThinkingDelta { thinking },
..
}) if thinking == "step 1"
));
assert!(matches!(
&events[3],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::SignatureDelta { signature },
..
}) if signature == "sig_123"
));
assert!(matches!(
&events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { text },
..
}) if text == "Final answer"
));
assert!(matches!(events[6], StreamEvent::ContentBlockStop(_)));
assert!(matches!(events[7], StreamEvent::MessageDelta(_)));
assert!(matches!(events[8], StreamEvent::MessageStop(_)));
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
@@ -364,6 +312,121 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_reuses_recent_completion_cache_entries() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_cached\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Cached once\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":4000,\"output_tokens\":2}}",
)],
)
.await;
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("integration-session"));
let first = client
.send_message(&sample_request(false))
.await
.expect("first request should succeed");
let second = client
.send_message(&sample_request(false))
.await
.expect("second request should reuse cache");
assert_eq!(first.content, second.content);
assert_eq!(state.lock().await.len(), 1);
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.completion_cache_hits, 1);
assert_eq!(cache_stats.completion_cache_misses, 1);
assert_eq!(cache_stats.completion_cache_writes, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_tracks_unexpected_prompt_cache_breaks() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-break-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state,
vec![
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_one\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"One\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":6000,\"output_tokens\":2}}",
),
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_two\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Two\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":1000,\"output_tokens\":2}}",
),
],
)
.await;
let request = sample_request(false);
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::with_config(api::PromptCacheConfig {
session_id: "break-session".to_string(),
completion_ttl: Duration::from_secs(0),
..api::PromptCacheConfig::default()
}));
client
.send_message(&request)
.await
.expect("first response should succeed");
client
.send_message(&request)
.await
.expect("second response should succeed");
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.unexpected_cache_breaks, 1);
assert_eq!(
cache_stats.last_break_reason.as_deref(),
Some("cache read tokens dropped while prompt fingerprint remained stable")
);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
async fn live_stream_smoke_test() {

View File

@@ -9,5 +9,4 @@ publish.workspace = true
workspace = true
[dependencies]
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }

File diff suppressed because it is too large Load Diff

View File

@@ -70,12 +70,16 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
}
for ancestor in primary_repo_root.ancestors().take(4) {
candidates.push(ancestor.join("claw-code"));
candidates.push(ancestor.join("claude-code"));
candidates.push(ancestor.join("clawd-code"));
}
candidates.push(primary_repo_root.join("reference-source").join("claw-code"));
candidates.push(primary_repo_root.join("vendor").join("claw-code"));
candidates.push(
primary_repo_root
.join("reference-source")
.join("claude-code"),
);
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
let mut deduped = Vec::new();
for candidate in candidates {

View File

@@ -1,13 +0,0 @@
[package]
name = "plugins"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints]
workspace = true

View File

@@ -1,10 +0,0 @@
{
"name": "example-bundled",
"version": "0.1.0",
"description": "Example bundled plugin scaffold for the Rust plugin system",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf '%s\n' 'example bundled post hook'

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf '%s\n' 'example bundled pre hook'

View File

@@ -1,10 +0,0 @@
{
"name": "sample-hooks",
"version": "0.1.0",
"description": "Bundled sample plugin scaffold for hook integration tests.",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf 'sample bundled post hook'

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf 'sample bundled pre hook'

View File

@@ -1,395 +0,0 @@
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use serde_json::json;
use crate::{PluginError, PluginHooks, PluginRegistry};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
}
impl HookEvent {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
messages: Vec<String>,
}
impl HookRunResult {
#[must_use]
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
messages,
}
}
#[must_use]
pub fn is_denied(&self) -> bool {
self.denied
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HookRunner {
hooks: PluginHooks,
}
impl HookRunner {
#[must_use]
pub fn new(hooks: PluginHooks) -> Self {
Self { hooks }
}
pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
Ok(Self::new(plugin_registry.aggregated_hooks()?))
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
tool_input,
None,
false,
)
}
#[must_use]
pub fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
tool_input,
Some(tool_output),
is_error,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match self.run_command(
command,
event,
tool_name,
tool_input,
tool_output,
is_error,
&payload,
) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
}
HookCommandOutcome::Deny { message } => {
messages.push(message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
}));
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
payload: &str,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", event.as_str());
child.env("HOOK_TOOL_NAME", tool_name);
child.env("HOOK_TOOL_INPUT", tool_input);
child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
if let Some(tool_output) = tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
),
},
}
}
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
),
},
}
}
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
} else if !stderr.is_empty() {
message.push_str(": ");
message.push_str(stderr);
}
message
}
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
};
#[cfg(not(windows))]
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
};
command_builder
}
struct CommandWithStdin {
command: Command,
}
impl CommandWithStdin {
fn new(command: Command) -> Self {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use crate::{PluginManager, PluginManagerConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
write_hook_plugin(
&first_source_root,
"first",
"plugin pre one",
"plugin post one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(first_source_root.to_str().expect("utf8 path"))
.expect("first plugin install should succeed");
manager
.install(second_source_root.to_str().expect("utf8 path"))
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
"plugin pre one".to_string(),
"plugin pre two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
HookRunResult::allow(vec![
"plugin post one".to_string(),
"plugin post two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
let _ = fs::remove_dir_all(second_source_root);
}
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
});
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ publish.workspace = true
[dependencies]
sha2 = "0.10"
glob = "0.3"
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,10 +1,5 @@
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
const COMPACT_CONTINUATION_PREAMBLE: &str =
"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";
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue 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.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -35,15 +30,8 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
let start = compacted_summary_prefix_len(session);
let compactable = &session.messages[start..];
compactable.len() > config.preserve_recent_messages
&& compactable
.iter()
.map(estimate_message_tokens)
.sum::<usize>()
>= config.max_estimated_tokens
session.messages.len() > config.preserve_recent_messages
&& estimate_session_tokens(session) >= config.max_estimated_tokens
}
#[must_use]
@@ -68,18 +56,16 @@ pub fn get_compact_continuation_message(
recent_messages_preserved: bool,
) -> String {
let mut base = format!(
"{COMPACT_CONTINUATION_PREAMBLE}{}",
"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\n");
base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
base.push_str("\n\nRecent messages are preserved verbatim.");
}
if suppress_follow_up_questions {
base.push('\n');
base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
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
@@ -96,19 +82,13 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
};
}
let existing_summary = session
.messages
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
let removed = &session.messages[compacted_prefix_len..keep_from];
let removed = &session.messages[..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary =
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
let summary = summarize_messages(removed);
let formatted_summary = format_compact_summary(&summary);
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
@@ -130,16 +110,6 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}
}
fn compacted_summary_prefix_len(session: &Session) -> usize {
usize::from(
session
.messages
.first()
.and_then(extract_existing_compacted_summary)
.is_some(),
)
}
fn summarize_messages(messages: &[ConversationMessage]) -> String {
let user_messages = messages
.iter()
@@ -227,41 +197,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
lines.join("\n")
}
fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
let Some(existing_summary) = existing_summary else {
return new_summary.to_string();
};
let previous_highlights = extract_summary_highlights(existing_summary);
let new_formatted_summary = format_compact_summary(new_summary);
let new_highlights = extract_summary_highlights(&new_formatted_summary);
let new_timeline = extract_summary_timeline(&new_formatted_summary);
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
if !previous_highlights.is_empty() {
lines.push("- Previously compacted context:".to_string());
lines.extend(
previous_highlights
.into_iter()
.map(|line| format!(" {line}")),
);
}
if !new_highlights.is_empty() {
lines.push("- Newly compacted context:".to_string());
lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
}
if !new_timeline.is_empty() {
lines.push("- Key timeline:".to_string());
lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
}
lines.push("</summary>".to_string());
lines.join("\n")
}
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
@@ -439,71 +374,11 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
if message.role != MessageRole::System {
return None;
}
let text = first_text_block(message)?;
let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
let summary = summary
.split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
.map_or(summary, |(value, _)| value);
let summary = summary
.split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
.map_or(summary, |(value, _)| value);
Some(summary.trim().to_string())
}
fn extract_summary_highlights(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
continue;
}
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if in_timeline {
continue;
}
lines.push(trimmed.to_string());
}
lines
}
fn extract_summary_timeline(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if !in_timeline {
continue;
}
if trimmed.is_empty() {
break;
}
lines.push(trimmed.to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
@@ -578,98 +453,6 @@ mod tests {
);
}
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
};
let first = compact_session(&initial_session, config);
let mut follow_up_messages = first.compacted_session.messages.clone();
follow_up_messages.extend([
ConversationMessage::user_text("Please add regression tests for compaction."),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Working on regression coverage now.".to_string(),
}]),
]);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
assert!(second
.formatted_summary
.contains("Previously compacted context:"));
assert!(second
.formatted_summary
.contains("Scope: 2 earlier messages compacted"));
assert!(second
.formatted_summary
.contains("Newly compacted context:"));
assert!(second
.formatted_summary
.contains("Also update rust/crates/runtime/src/conversation.rs"));
assert!(matches!(
&second.compacted_session.messages[0].blocks[0],
ContentBlock::Text { text }
if text.contains("Previously compacted context:")
&& text.contains("Newly compacted context:")
));
assert!(matches!(
&second.compacted_session.messages[1].blocks[0],
ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
));
}
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
assert!(!should_compact(
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
}
));
}
#[test]
fn truncates_long_blocks_in_summary() {
let summary = super::summarize_block(&ContentBlock::Text {

View File

@@ -35,24 +35,13 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePluginConfig {
enabled_plugins: BTreeMap<String, bool>,
external_directories: Vec<String>,
install_root: Option<String>,
registry_path: Option<String>,
bundled_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
}
@@ -60,14 +49,6 @@ pub struct RuntimeFeatureConfig {
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
allow: Vec<String>,
deny: Vec<String>,
ask: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -193,15 +174,13 @@ impl ConfigLoader {
#[must_use]
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
let cwd = cwd.into();
let config_home = default_config_home();
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 config_home(&self) -> &Path {
&self.config_home
}
#[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
@@ -250,14 +229,12 @@ impl ConfigLoader {
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
};
@@ -314,11 +291,6 @@ impl RuntimeConfig {
&self.feature_config.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.feature_config.plugins
}
#[must_use]
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref()
@@ -334,11 +306,6 @@ impl RuntimeConfig {
self.feature_config.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.feature_config.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.feature_config.sandbox
@@ -352,22 +319,11 @@ impl RuntimeFeatureConfig {
self
}
#[must_use]
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
self.plugins = plugins;
self
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.plugins
}
#[must_use]
pub fn mcp(&self) -> &McpConfigCollection {
&self.mcp
@@ -388,75 +344,18 @@ impl RuntimeFeatureConfig {
self.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
}
}
impl RuntimePluginConfig {
#[must_use]
pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
&self.enabled_plugins
}
#[must_use]
pub fn external_directories(&self) -> &[String] {
&self.external_directories
}
#[must_use]
pub fn install_root(&self) -> Option<&str> {
self.install_root.as_deref()
}
#[must_use]
pub fn registry_path(&self) -> Option<&str> {
self.registry_path.as_deref()
}
#[must_use]
pub fn bundled_root(&self) -> Option<&str> {
self.bundled_root.as_deref()
}
pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
self.enabled_plugins.insert(plugin_id, enabled);
}
#[must_use]
pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
self.enabled_plugins
.get(plugin_id)
.copied()
.unwrap_or(default_enabled)
}
}
#[must_use]
pub fn default_config_home() -> PathBuf {
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"))
}
impl RuntimeHookConfig {
#[must_use]
pub fn new(
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
) -> Self {
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
Self {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
}
}
@@ -469,49 +368,6 @@ impl RuntimeHookConfig {
pub fn post_tool_use(&self) -> &[String] {
&self.post_tool_use
}
#[must_use]
pub fn post_tool_use_failure(&self) -> &[String] {
&self.post_tool_use_failure
}
#[must_use]
pub fn merged(&self, other: &Self) -> Self {
let mut merged = self.clone();
merged.extend(other);
merged
}
pub fn extend(&mut self, other: &Self) {
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
extend_unique(&mut self.post_tool_use, other.post_tool_use());
extend_unique(
&mut self.post_tool_use_failure,
other.post_tool_use_failure(),
);
}
}
impl RuntimePermissionRuleConfig {
#[must_use]
pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
Self { allow, deny, ask }
}
#[must_use]
pub fn allow(&self) -> &[String] {
&self.allow
}
#[must_use]
pub fn deny(&self) -> &[String] {
&self.deny
}
#[must_use]
pub fn ask(&self) -> &[String] {
&self.ask
}
}
impl McpConfigCollection {
@@ -625,65 +481,9 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
post_tool_use_failure: optional_string_array(
hooks,
"PostToolUseFailure",
"merged settings.hooks",
)?
.unwrap_or_default(),
})
}
fn parse_optional_permission_rules(
root: &JsonValue,
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePermissionRuleConfig::default());
};
let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
return Ok(RuntimePermissionRuleConfig::default());
};
Ok(RuntimePermissionRuleConfig {
allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
.unwrap_or_default(),
deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
.unwrap_or_default(),
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
.unwrap_or_default(),
})
}
fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePluginConfig::default());
};
let mut config = RuntimePluginConfig::default();
if let Some(enabled_plugins) = object.get("enabledPlugins") {
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
}
let Some(plugins_value) = object.get("plugins") else {
return Ok(config);
};
let plugins = expect_object(plugins_value, "merged settings.plugins")?;
if let Some(enabled_value) = plugins.get("enabled") {
config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
}
config.external_directories =
optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
.unwrap_or_default();
config.install_root =
optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
config.registry_path =
optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
config.bundled_root =
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
Ok(config)
}
fn parse_optional_permission_mode(
root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -916,24 +716,6 @@ fn optional_u16(
}
}
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
let Some(map) = value.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: expected JSON object"
)));
};
map.iter()
.map(|(key, value)| {
value
.as_bool()
.map(|enabled| (key.clone(), enabled))
.ok_or_else(|| {
ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
})
})
.collect()
}
fn optional_string_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
@@ -1008,18 +790,6 @@ fn deep_merge_objects(
}
}
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
push_unique(target, value.clone());
}
}
fn push_unique(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -1073,7 +843,7 @@ mod tests {
.expect("write user compat config");
fs::write(
home.join("settings.json"),
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
)
.expect("write user settings");
fs::write(
@@ -1083,7 +853,7 @@ mod tests {
.expect("write project compat config");
fs::write(
cwd.join(".claude").join("settings.json"),
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
@@ -1128,16 +898,6 @@ mod tests {
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert_eq!(
loaded.hooks().post_tool_use_failure(),
&["project-failure".to_string()]
);
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
assert_eq!(
loaded.permission_rules().deny(),
&["Bash(rm -rf)".to_string()]
);
assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
@@ -1273,96 +1033,6 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config_from_enabled_plugins() {
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#"{
"enabledPlugins": {
"tool-guard@builtin": true,
"sample-plugin@external": false
}
}"#,
)
.expect("write user settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
Some(&true)
);
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("sample-plugin@external"),
Some(&false)
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config() {
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#"{
"enabledPlugins": {
"core-helpers@builtin": true
},
"plugins": {
"externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins"
}
}"#,
)
.expect("write plugin settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("core-helpers@builtin"),
Some(&true)
);
assert_eq!(
loaded.plugins().external_directories(),
&["./external-plugins".to_string()]
);
assert_eq!(
loaded.plugins().install_root(),
Some("plugin-cache/installed")
);
assert_eq!(
loaded.plugins().registry_path(),
Some("plugin-cache/installed.json")
);
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_invalid_mcp_server_shapes() {
let root = temp_dir();

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +1,29 @@
use std::ffi::OsStr;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
use std::process::Command;
use serde_json::{json, Value};
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
pub type HookPermissionDecision = PermissionOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
#[must_use]
pub fn as_str(self) -> &'static str {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookProgressEvent {
Started {
event: HookEvent,
tool_name: String,
command: String,
},
Completed {
event: HookEvent,
tool_name: String,
command: String,
},
Cancelled {
event: HookEvent,
tool_name: String,
command: String,
},
}
pub trait HookProgressReporter {
fn on_event(&mut self, event: &HookProgressEvent);
}
#[derive(Debug, Clone, Default)]
pub struct HookAbortSignal {
aborted: Arc<AtomicBool>,
}
impl HookAbortSignal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn abort(&self) {
self.aborted.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn is_aborted(&self) -> bool {
self.aborted.load(Ordering::SeqCst)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
cancelled: bool,
messages: Vec<String>,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl HookRunResult {
@@ -93,11 +31,7 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
cancelled: false,
messages,
permission_override: None,
permission_reason: None,
updated_input: None,
}
}
@@ -106,40 +40,10 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
#[must_use]
pub fn permission_override(&self) -> Option<PermissionOverride> {
self.permission_override
}
#[must_use]
pub fn permission_decision(&self) -> Option<HookPermissionDecision> {
self.permission_override
}
#[must_use]
pub fn permission_reason(&self) -> Option<&str> {
self.permission_reason.as_deref()
}
#[must_use]
pub fn updated_input(&self) -> Option<&str> {
self.updated_input.as_deref()
}
#[must_use]
pub fn updated_input_json(&self) -> Option<&str> {
self.updated_input()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -160,17 +64,6 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, None, None)
}
#[must_use]
pub fn run_pre_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
@@ -178,21 +71,9 @@ impl HookRunner {
tool_input,
None,
false,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_pre_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None)
}
#[must_use]
pub fn run_post_tool_use(
&self,
@@ -200,26 +81,6 @@ impl HookRunner {
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
None,
None,
)
}
#[must_use]
pub fn run_post_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUse,
@@ -228,79 +89,9 @@ impl HookRunner {
tool_input,
Some(tool_output),
is_error,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
abort_signal,
None,
)
}
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None)
}
#[must_use]
pub fn run_post_tool_use_failure_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(),
tool_name,
tool_input,
Some(tool_error),
true,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_failure_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(
tool_name,
tool_input,
tool_error,
abort_signal,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn run_commands(
event: HookEvent,
commands: &[String],
@@ -308,39 +99,24 @@ impl HookRunner {
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
mut reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
return HookRunResult {
denied: false,
cancelled: true,
messages: vec![format!(
"{} hook cancelled before execution",
event.as_str()
)],
permission_override: None,
permission_reason: None,
updated_input: None,
};
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new());
let mut messages = Vec::new();
for command in commands {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
match Self::run_command(
command,
event,
@@ -349,59 +125,29 @@ impl HookRunner {
tool_output,
is_error,
&payload,
abort_signal,
) {
HookCommandOutcome::Allow { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
merge_parsed_hook_output(&mut result, parsed);
}
HookCommandOutcome::Deny { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
merge_parsed_hook_output(&mut result, parsed);
result.denied = true;
return result;
}
HookCommandOutcome::Warn { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.messages.push(message);
}
HookCommandOutcome::Cancelled { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Cancelled {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.cancelled = true;
result.messages.push(message);
return result;
HookCommandOutcome::Deny { message } => {
let message = message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
});
messages.push(message);
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
result
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments)]
fn run_command(
command: &str,
event: HookEvent,
@@ -410,12 +156,11 @@ impl HookRunner {
tool_output: Option<&str>,
is_error: bool,
payload: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(Stdio::piped());
child.stdout(Stdio::piped());
child.stderr(Stdio::piped());
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", event.as_str());
child.env("HOOK_TOOL_NAME", tool_name);
child.env("HOOK_TOOL_INPUT", tool_input);
@@ -424,30 +169,19 @@ impl HookRunner {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(payload.as_bytes(), abort_signal) {
Ok(CommandExecution::Finished(output)) => {
match child.output_with_stdin(payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let parsed = parse_hook_output(&stdout);
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => {
if parsed.deny {
HookCommandOutcome::Deny { parsed }
} else {
HookCommandOutcome::Allow { parsed }
}
}
Some(2) => HookCommandOutcome::Deny {
parsed: parsed.with_fallback_message(format!(
"{} hook denied tool `{tool_name}`",
event.as_str()
)),
},
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
parsed.primary_message(),
message.as_deref(),
stderr.as_str(),
),
},
@@ -459,12 +193,6 @@ impl HookRunner {
},
}
}
Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled {
message: format!(
"{} hook `{command}` cancelled while handling `{tool_name}`",
event.as_str()
),
},
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
@@ -476,131 +204,12 @@ impl HookRunner {
}
enum HookCommandOutcome {
Allow { parsed: ParsedHookOutput },
Deny { parsed: ParsedHookOutput },
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
Cancelled { message: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct ParsedHookOutput {
messages: Vec<String>,
deny: bool,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl ParsedHookOutput {
fn with_fallback_message(mut self, fallback: String) -> Self {
if self.messages.is_empty() {
self.messages.push(fallback);
}
self
}
fn primary_message(&self) -> Option<&str> {
self.messages.first().map(String::as_str)
}
}
fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) {
target.messages.extend(parsed.messages);
if parsed.permission_override.is_some() {
target.permission_override = parsed.permission_override;
}
if parsed.permission_reason.is_some() {
target.permission_reason = parsed.permission_reason;
}
if parsed.updated_input.is_some() {
target.updated_input = parsed.updated_input;
}
}
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
if stdout.is_empty() {
return ParsedHookOutput::default();
}
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
};
let mut parsed = ParsedHookOutput::default();
if let Some(message) = root.get("systemMessage").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if let Some(message) = root.get("reason").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if root.get("continue").and_then(Value::as_bool) == Some(false)
|| root.get("decision").and_then(Value::as_str) == Some("block")
{
parsed.deny = true;
}
if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") {
if let Some(Value::String(additional_context)) = specific.get("additionalContext") {
parsed.messages.push(additional_context.clone());
}
if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) {
parsed.permission_override = match decision {
"allow" => Some(PermissionOverride::Allow),
"deny" => Some(PermissionOverride::Deny),
"ask" => Some(PermissionOverride::Ask),
_ => None,
};
}
if let Some(reason) = specific
.get("permissionDecisionReason")
.and_then(Value::as_str)
{
parsed.permission_reason = Some(reason.to_string());
}
if let Some(updated_input) = specific.get("updatedInput") {
parsed.updated_input = serde_json::to_string(updated_input).ok();
}
}
if parsed.messages.is_empty() {
parsed.messages.push(stdout.to_string());
}
parsed
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
}),
}
}
fn parse_tool_input(tool_input: &str) -> Value {
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
@@ -626,11 +235,7 @@ fn shell_command(command: &str) -> CommandWithStdin {
};
#[cfg(not(windows))]
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let command_builder = {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
@@ -648,17 +253,17 @@ impl CommandWithStdin {
Self { command }
}
fn stdin(&mut self, cfg: Stdio) -> &mut Self {
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: Stdio) -> &mut Self {
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: Stdio) -> &mut Self {
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
@@ -672,64 +277,26 @@ impl CommandWithStdin {
self
}
fn output_with_stdin(
&mut self,
stdin: &[u8],
abort_signal: Option<&HookAbortSignal>,
) -> std::io::Result<CommandExecution> {
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write;
child_stdin.write_all(stdin)?;
}
loop {
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
let _ = child.kill();
let _ = child.wait_with_output();
return Ok(CommandExecution::Cancelled);
}
match child.try_wait()? {
Some(_) => return child.wait_with_output().map(CommandExecution::Finished),
None => thread::sleep(Duration::from_millis(20)),
}
}
child.wait_with_output()
}
}
enum CommandExecution {
Finished(std::process::Output),
Cancelled,
}
#[cfg(test)]
mod tests {
use std::thread;
use std::time::Duration;
use super::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner,
};
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
struct RecordingReporter {
events: Vec<HookProgressEvent>,
}
impl HookProgressReporter for RecordingReporter {
fn on_event(&mut self, event: &HookProgressEvent) {
self.events.push(event.clone());
}
}
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre ok'")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
@@ -742,7 +309,6 @@ mod tests {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
@@ -757,7 +323,6 @@ mod tests {
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
Vec::new(),
Vec::new(),
),
));
@@ -770,82 +335,6 @@ mod tests {
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
fn parses_pre_hook_permission_override_and_updated_input() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet(
r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
)],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#);
assert_eq!(
result.permission_override(),
Some(PermissionOverride::Allow)
);
assert_eq!(result.permission_reason(), Some("hook ok"));
assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#));
assert!(result.messages().iter().any(|message| message == "updated"));
}
#[test]
fn runs_post_tool_use_failure_hooks() {
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![shell_snippet("printf 'failure hook ran'")],
));
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
assert!(!result.is_denied());
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
}
#[test]
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("sleep 5")],
Vec::new(),
Vec::new(),
));
let abort_signal = HookAbortSignal::new();
let abort_signal_for_thread = abort_signal.clone();
let mut reporter = RecordingReporter { events: Vec::new() };
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
abort_signal_for_thread.abort();
});
let result = runner.run_pre_tool_use_with_context(
"bash",
r#"{"command":"sleep 5"}"#,
Some(&abort_signal),
Some(&mut reporter),
);
assert!(result.is_cancelled());
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Started {
event: HookEvent::PreToolUse,
..
}
)));
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Cancelled {
event: HookEvent::PreToolUse,
..
}
)));
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")

View File

@@ -28,21 +28,18 @@ pub use config::{
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
@@ -67,8 +64,8 @@ pub use oauth::{
PkceChallengeMethod, PkceCodePair,
};
pub use permissions::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,

View File

@@ -1,9 +1,5 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
@@ -26,49 +22,12 @@ impl PermissionMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionOverride {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PermissionContext {
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
}
impl PermissionContext {
#[must_use]
pub fn new(
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
) -> Self {
Self {
override_decision,
override_reason,
}
}
#[must_use]
pub fn override_decision(&self) -> Option<PermissionOverride> {
self.override_decision
}
#[must_use]
pub fn override_reason(&self) -> Option<&str> {
self.override_reason.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -91,9 +50,6 @@ pub enum PermissionOutcome {
pub struct PermissionPolicy {
active_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>,
allow_rules: Vec<PermissionRule>,
deny_rules: Vec<PermissionRule>,
ask_rules: Vec<PermissionRule>,
}
impl PermissionPolicy {
@@ -102,9 +58,6 @@ impl PermissionPolicy {
Self {
active_mode,
tool_requirements: BTreeMap::new(),
allow_rules: Vec::new(),
deny_rules: Vec::new(),
ask_rules: Vec::new(),
}
}
@@ -119,26 +72,6 @@ impl PermissionPolicy {
self
}
#[must_use]
pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
self.allow_rules = config
.allow()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.deny_rules = config
.deny()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.ask_rules = config
.ask()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.active_mode
@@ -157,121 +90,38 @@ impl PermissionPolicy {
&self,
tool_name: &str,
input: &str,
prompter: Option<&mut dyn PermissionPrompter>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn authorize_with_context(
&self,
tool_name: &str,
input: &str,
context: &PermissionContext,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
return PermissionOutcome::Deny {
reason: format!(
"Permission to use {tool_name} has been denied by rule '{}'",
rule.raw
),
};
}
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
match context.override_decision() {
Some(PermissionOverride::Deny) => {
return PermissionOutcome::Deny {
reason: context.override_reason().map_or_else(
|| format!("tool '{tool_name}' denied by hook"),
ToOwned::to_owned,
),
};
}
Some(PermissionOverride::Ask) => {
let reason = context.override_reason().map_or_else(
|| format!("tool '{tool_name}' requires approval due to hook guidance"),
ToOwned::to_owned,
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
}
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
return PermissionOutcome::Allow;
}
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
};
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
));
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
reason,
prompter,
);
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
},
};
}
PermissionOutcome::Deny {
@@ -282,191 +132,14 @@ impl PermissionPolicy {
),
}
}
fn prompt_or_deny(
tool_name: &str,
input: &str,
current_mode: PermissionMode,
required_mode: PermissionMode,
reason: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
reason: reason.clone(),
};
match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: reason.unwrap_or_else(|| {
format!(
"tool '{tool_name}' requires approval to run while mode is {}",
current_mode.as_str()
)
}),
},
}
}
fn find_matching_rule<'a>(
rules: &'a [PermissionRule],
tool_name: &str,
input: &str,
) -> Option<&'a PermissionRule> {
rules.iter().find(|rule| rule.matches(tool_name, input))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionRule {
raw: String,
tool_name: String,
matcher: PermissionRuleMatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PermissionRuleMatcher {
Any,
Exact(String),
Prefix(String),
}
impl PermissionRule {
fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let open = find_first_unescaped(trimmed, '(');
let close = find_last_unescaped(trimmed, ')');
if let (Some(open), Some(close)) = (open, close) {
if close == trimmed.len() - 1 && open < close {
let tool_name = trimmed[..open].trim();
let content = &trimmed[open + 1..close];
if !tool_name.is_empty() {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
matcher,
};
}
}
}
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
fn matches(&self, tool_name: &str, input: &str) -> bool {
if self.tool_name != tool_name {
return false;
}
match &self.matcher {
PermissionRuleMatcher::Any => true,
PermissionRuleMatcher::Exact(expected) => {
extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
}
PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
.is_some_and(|candidate| candidate.starts_with(prefix)),
}
}
}
fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
let unescaped = unescape_rule_content(content.trim());
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
}
}
fn unescape_rule_content(content: &str) -> String {
content
.replace(r"\(", "(")
.replace(r"\)", ")")
.replace(r"\\", r"\")
}
fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
let mut escaped = false;
for (idx, ch) in value.char_indices() {
if ch == '\\' {
escaped = !escaped;
continue;
}
if ch == needle && !escaped {
return Some(idx);
}
escaped = false;
}
None
}
fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
let chars = value.char_indices().collect::<Vec<_>>();
for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
if *ch != needle {
continue;
}
let mut backslashes = 0;
for (_, prev) in chars[..pos].iter().rev() {
if *prev == '\\' {
backslashes += 1;
} else {
break;
}
}
if backslashes % 2 == 0 {
return Some(*idx);
}
}
None
}
fn extract_permission_subject(input: &str) -> Option<String> {
let parsed = serde_json::from_str::<Value>(input).ok();
if let Some(Value::Object(object)) = parsed {
for key in [
"command",
"path",
"file_path",
"filePath",
"notebook_path",
"notebookPath",
"url",
"pattern",
"code",
"message",
] {
if let Some(value) = object.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
}
(!input.trim().is_empty()).then(|| input.to_string())
}
#[cfg(test)]
mod tests {
use super::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
use crate::config::RuntimePermissionRuleConfig;
struct RecordingPrompter {
seen: Vec<PermissionRequest>,
@@ -556,120 +229,4 @@ mod tests {
PermissionOutcome::Deny { reason } if reason == "not now"
));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
vec!["bash(git:*)".to_string()],
vec!["bash(rm -rf:*)".to_string()],
Vec::new(),
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
assert_eq!(
policy.authorize("bash", r#"{"command":"git status"}"#, None),
PermissionOutcome::Allow
);
assert!(matches!(
policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
));
}
#[test]
fn ask_rules_force_prompt_even_when_mode_allows() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert!(prompter.seen[0]
.reason
.as_deref()
.is_some_and(|reason| reason.contains("ask rule")));
}
#[test]
fn hook_allow_still_respects_ask_rules() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let context = PermissionContext::new(
Some(PermissionOverride::Allow),
Some("hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context(
"bash",
r#"{"command":"git status"}"#,
&context,
Some(&mut prompter),
);
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
}
#[test]
fn hook_deny_short_circuits_permission_flow() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Deny),
Some("blocked by hook".to_string()),
);
assert_eq!(
policy.authorize_with_context("bash", "{}", &context, None),
PermissionOutcome::Deny {
reason: "blocked by hook".to_string(),
}
);
}
#[test]
fn hook_ask_forces_prompt() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Ask),
Some("hook requested confirmation".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(
prompter.seen[0].reason.as_deref(),
Some("hook requested confirmation")
);
}
}

View File

@@ -421,7 +421,7 @@ 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 Claw Code settings files loaded.".to_string()
"No Claude Code settings files loaded.".to_string(),
]));
return lines.join("\n");
}

View File

@@ -17,10 +17,9 @@ crossterm = "0.28"
pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
serde_json = "1"
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" }
[lints]

View File

@@ -8,7 +8,7 @@ const STARTER_CLAUDE_JSON: &str = concat!(
" }\n",
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ publish.workspace = true
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -5,16 +5,15 @@ use std::time::{Duration, Instant};
use api::{
read_base_url, AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage,
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
MessageRequest, MessageResponse, OutputContentBlock, PromptCache, PromptCacheRecord,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use plugins::{PluginManager, PluginManagerConfig, PluginTool};
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
PromptCacheEvent, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -56,239 +55,6 @@ pub struct ToolSpec {
pub required_permission: PermissionMode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredTool {
pub definition: ToolDefinition,
pub required_permission: PermissionMode,
handler: RegisteredToolHandler,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
enum RegisteredToolHandler {
Builtin,
Plugin(PluginTool),
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlobalToolRegistry {
entries: Vec<RegisteredTool>,
}
impl GlobalToolRegistry {
#[must_use]
pub fn builtin() -> Self {
Self {
entries: mvp_tool_specs()
.into_iter()
.map(|spec| RegisteredTool {
definition: ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
},
required_permission: spec.required_permission,
handler: RegisteredToolHandler::Builtin,
})
.collect(),
}
}
pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
let mut registry = Self::builtin();
let mut seen = registry
.entries
.iter()
.map(|entry| {
(
normalize_registry_tool_name(&entry.definition.name),
entry.definition.name.clone(),
)
})
.collect::<BTreeMap<_, _>>();
for tool in plugin_tools {
let normalized = normalize_registry_tool_name(&tool.definition().name);
if let Some(existing) = seen.get(&normalized) {
return Err(format!(
"plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`",
tool.definition().name,
tool.plugin_id()
));
}
seen.insert(normalized, tool.definition().name.clone());
registry.entries.push(RegisteredTool {
definition: ToolDefinition {
name: tool.definition().name.clone(),
description: tool.definition().description.clone(),
input_schema: tool.definition().input_schema.clone(),
},
required_permission: permission_mode_from_plugin_tool(tool.required_permission())?,
handler: RegisteredToolHandler::Plugin(tool),
});
}
Ok(registry)
}
#[must_use]
pub fn entries(&self) -> &[RegisteredTool] {
&self.entries
}
fn find_entry(&self, name: &str) -> Option<&RegisteredTool> {
let normalized = normalize_registry_tool_name(name);
self.entries.iter().find(|entry| {
normalize_registry_tool_name(entry.definition.name.as_str()) == normalized
})
}
#[must_use]
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| entry.definition.clone())
.collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
) -> Vec<(String, PermissionMode)> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| (entry.definition.name.clone(), entry.required_permission))
.collect()
}
pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = self
.entries
.iter()
.map(|entry| entry.definition.name.clone())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_registry_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
if canonical_names.iter().any(|name| name == canonical) {
name_map.insert(alias.to_string(), canonical.to_string());
}
}
let mut allowed = BTreeSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_registry_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
let entry = self
.find_entry(name)
.ok_or_else(|| format!("unsupported tool: {name}"))?;
match &entry.handler {
RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input),
RegisteredToolHandler::Plugin(tool) => {
tool.execute(input).map_err(|error| error.to_string())
}
}
}
}
impl Default for GlobalToolRegistry {
fn default() -> Self {
Self::builtin()
}
}
fn normalize_registry_tool_name(value: &str) -> String {
let trimmed = value.trim();
let chars = trimmed.chars().collect::<Vec<_>>();
let mut normalized = String::new();
for (index, ch) in chars.iter().copied().enumerate() {
if matches!(ch, '-' | ' ' | '\t' | '\n') {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch == '_' {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch.is_uppercase() {
let prev = chars.get(index.wrapping_sub(1)).copied();
let next = chars.get(index + 1).copied();
let needs_separator = index > 0
&& !normalized.ends_with('_')
&& (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit())
|| (prev.is_some_and(char::is_uppercase)
&& next.is_some_and(char::is_lowercase)));
if needs_separator {
normalized.push('_');
}
normalized.extend(ch.to_lowercase());
continue;
}
normalized.push(ch.to_ascii_lowercase());
}
normalized.trim_matches('_').to_string()
}
fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> {
match value {
"read-only" => Ok(PermissionMode::ReadOnly),
"workspace-write" => Ok(PermissionMode::WorkspaceWrite),
"danger-full-access" => Ok(PermissionMode::DangerFullAccess),
other => Err(format!(
"unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)"
)),
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
@@ -557,7 +323,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "Config",
description: "Get or set Claw Code settings.",
description: "Get or set Claude Code settings.",
input_schema: json!({
"type": "object",
"properties": {
@@ -1700,15 +1466,14 @@ fn build_agent_runtime(
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let allowed_tools = job.allowed_tools.clone();
let tool_registry = current_tool_registry()?;
let api_client =
AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone());
AnthropicRuntimeClient::new(model, allowed_tools.clone(), job.manifest.agent_id.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools);
Ok(ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
agent_permission_policy(&tool_registry),
agent_permission_policy(),
job.system_prompt.clone(),
))
}
@@ -1773,7 +1538,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"SendUserMessage",
"PowerShell",
],
"claw-code-guide" => vec![
"claude-code-guide" => vec![
"read_file",
"glob_search",
"grep_search",
@@ -1817,12 +1582,10 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
tools.into_iter().map(str::to_string).collect()
}
fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
tool_registry.permission_specs(None).into_iter().fold(
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, (name, required_permission)| {
policy.with_tool_requirement(name, required_permission)
},
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
)
}
@@ -1878,31 +1641,38 @@ struct AnthropicRuntimeClient {
client: AnthropicClient,
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl AnthropicRuntimeClient {
fn new(
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
session_id: impl Into<String>,
) -> Result<Self, String> {
let client = AnthropicClient::from_env()
.map_err(|error| error.to_string())?
.with_base_url(read_base_url());
.with_base_url(read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
client,
model,
allowed_tools,
tool_registry,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
#[allow(clippy::too_many_lines)]
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect::<Vec<_>>();
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32_000,
@@ -1953,8 +1723,6 @@ impl ApiClient for AnthropicRuntimeClient {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
@@ -1965,8 +1733,8 @@ impl ApiClient for AnthropicRuntimeClient {
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: delta.usage.cache_creation_input_tokens,
cache_read_input_tokens: delta.usage.cache_read_input_tokens,
}));
}
ApiStreamEvent::MessageStop(_) => {
@@ -1976,6 +1744,8 @@ impl ApiClient for AnthropicRuntimeClient {
}
}
push_prompt_cache_record(&self.client, &mut events);
if !saw_stop
&& events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
@@ -2000,89 +1770,41 @@ impl ApiClient for AnthropicRuntimeClient {
})
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
Ok(response_to_events(response))
let mut events = response_to_events(response);
push_prompt_cache_record(&self.client, &mut events);
Ok(events)
})
}
}
struct SubagentToolExecutor {
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
Self {
allowed_tools,
tool_registry,
}
fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools }
}
}
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
let entry = self
.tool_registry
.find_entry(tool_name)
.ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?;
if !self.allowed_tools.contains(entry.definition.name.as_str()) {
if !self.allowed_tools.contains(tool_name) {
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
execute_tool(tool_name, &value).map_err(ToolError::new)
}
}
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load().map_err(|error| error.to_string())?;
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let plugin_tools = plugin_manager
.aggregated_tools()
.map_err(|error| error.to_string())?;
GlobalToolRegistry::with_plugin_tools(plugin_tools)
}
fn build_plugin_manager(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &RuntimeConfig,
) -> PluginManager {
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
PluginManager::new(plugin_config)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -2149,7 +1871,6 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
}
}
@@ -2174,6 +1895,26 @@ fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
events
}
fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<AssistantEvent>) {
if let Some(event) = client
.take_last_prompt_cache_record()
.and_then(prompt_cache_record_to_runtime_event)
{
events.push(AssistantEvent::PromptCache(event));
}
}
fn prompt_cache_record_to_runtime_event(record: PromptCacheRecord) -> Option<PromptCacheEvent> {
let cache_break = record.cache_break?;
Some(PromptCacheEvent {
unexpected: cache_break.unexpected,
reason: cache_break.reason,
previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
token_drop: cache_break.token_drop,
})
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
@@ -2377,7 +2118,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
"verification" | "verificationagent" | "verify" | "verifier" => {
String::from("Verification")
}
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"),
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
_ => trimmed.to_string(),
}
@@ -3195,13 +2936,9 @@ mod tests {
use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use runtime::{
ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
AgentInput, AgentJob, SubagentToolExecutor,
};
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
use serde_json::json;
fn env_lock() -> &'static Mutex<()> {
@@ -3217,17 +2954,6 @@ mod tests {
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
}
fn make_executable(path: &PathBuf) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
#[test]
fn exposes_mvp_tools() {
let names = mvp_tool_specs()
@@ -3257,170 +2983,6 @@ mod tests {
assert!(error.contains("unsupported tool"));
}
#[test]
fn global_registry_registers_and_executes_plugin_tools() {
let script = temp_path("plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
"sh".to_string(),
vec![script.display().to_string()],
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let names = registry
.definitions(None)
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash".to_string()));
assert!(names.contains(&"plugin_echo".to_string()));
let output = registry
.execute("plugin_echo", &json!({ "message": "hello" }))
.expect("plugin tool should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["plugin"], "demo@external");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() {
let script = temp_path("plugin-tool-normalized.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let allowed = registry
.normalize_allowed_tools(&[String::from("PLUGIN-ECHO")])
.expect("plugin tool allowlist should normalize")
.expect("allowlist should be present");
assert!(allowed.contains("plugin_echo"));
let output = registry
.execute("plugin-echo", &json!({ "message": "hello" }))
.expect("normalized plugin tool name should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let builtin_output = GlobalToolRegistry::builtin()
.execute("structured-output", &json!({ "ok": true }))
.expect("normalized builtin tool name should execute");
let builtin_payload: serde_json::Value =
serde_json::from_str(&builtin_output).expect("valid json");
assert_eq!(builtin_payload["structured_output"]["ok"], true);
let _ = std::fs::remove_file(script);
}
#[test]
fn subagent_executor_executes_allowed_plugin_tools() {
let script = temp_path("subagent-plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let mut executor =
SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry);
let output = executor
.execute("plugin-echo", r#"{"message":"hello"}"#)
.expect("plugin tool should execute for subagent");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_rejects_conflicting_plugin_tool_names() {
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "read-file".to_string(),
description: Some("Conflicts with builtin".to_string()),
input_schema: json!({ "type": "object" }),
},
"echo".to_string(),
Vec::new(),
PluginToolPermission::ReadOnly,
None,
)])
.expect_err("conflicting plugin tool should fail");
assert!(error.contains("conflicts with already-registered tool `read_file`"));
}
#[test]
fn web_fetch_returns_prompt_aware_summary() {
let server = TestServer::spawn(Arc::new(|request_line: &str| {
@@ -4000,11 +3562,8 @@ mod tests {
calls: 0,
input_path: path.display().to_string(),
},
SubagentToolExecutor::new(
BTreeSet::from([String::from("read_file")]),
GlobalToolRegistry::builtin(),
),
agent_permission_policy(&GlobalToolRegistry::builtin()),
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
agent_permission_policy(),
vec![String::from("system prompt")],
);
@@ -4030,42 +3589,6 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn response_to_events_ignores_thinking_blocks() {
let events = response_to_events(MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
});
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
assert!(!events
.iter()
.any(|event| matches!(event, AssistantEvent::ToolUse { .. })));
}
#[test]
fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool(