Compare commits

..

3 Commits

Author SHA1 Message Date
Yeachan-Heo
ccebabe605 Preserve verified session persistence while syncing remote runtime branch history
Origin/rcc/runtime advanced independently while this branch implemented
conversation history persistence. This merge keeps the tested local tree
as the source of truth for the user-requested feature while recording the
remote branch tip so future work can proceed from a shared history.

Constraint: Push required incorporating origin/rcc/runtime history without breaking the verified session-persistence implementation
Rejected: Force-push over origin/rcc/runtime | would discard remote branch history
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Before the next broad CLI/runtime refactor, compare this branch against origin/rcc/runtime for any remote-only startup behavior worth porting deliberately
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Remote-only runtime startup semantics not exercised by the session persistence change
2026-04-01 01:02:05 +00:00
Yeachan-Heo
146260083c Persist CLI conversation history across sessions
The Rust CLI now stores managed sessions under ~/.claude/sessions,
records additive session metadata in the canonical JSON transcript,
and exposes a /sessions listing alias alongside ID-or-path resume.
Inactive oversized sessions are compacted automatically so old
transcripts remain resumable without growing unchecked.

Constraint: Session JSON must stay backward-compatible with legacy files that lack metadata
Constraint: Managed sessions must use a single canonical JSON file per session without new dependencies
Rejected: Sidecar metadata/index files | duplicated state and diverged from the requested single-file persistence model
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep CLI policy in the CLI; only add transcript-adjacent metadata to runtime::Session unless another consumer truly needs more
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive REPL smoke test against the live Anthropic API
2026-04-01 00:58:14 +00:00
Yeachan-Heo
cd01d0e387 Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at
user and project scope, merges them in precedence order, and carries the
resolved model, permission mode, instruction files, and MCP server
configuration into session startup.

This keeps CLI defaults aligned with project policy and exposes configured
MCP tools without requiring manual flags.

Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts
Constraint: Session startup must preserve CLI flag precedence over config defaults
Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers
Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features
Not-tested: Live remote MCP servers and interactive REPL session startup against external services
2026-04-01 00:36:32 +00:00
10 changed files with 365 additions and 608 deletions

2
rust/Cargo.lock generated
View File

@@ -1431,12 +1431,10 @@ dependencies = [
name = "tools" name = "tools"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"api",
"reqwest", "reqwest",
"runtime", "runtime",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
] ]
[[package]] [[package]]

View File

@@ -133,6 +133,7 @@ Inside the REPL, useful commands include:
/diff /diff
/version /version
/export notes.txt /export notes.txt
/sessions
/session list /session list
/exit /exit
``` ```
@@ -143,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
``` ```
You can also inspect memory/config state for a restored session: You can also inspect memory/config state for a restored session:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session.json /memory /config cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
``` ```
## Available commands ## Available commands
@@ -158,7 +159,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
### Top-level CLI commands ### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively - `prompt <text...>` — run one prompt non-interactively
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session - `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `dump-manifests` — print extracted upstream manifest counts - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
@@ -176,13 +177,14 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session - `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals - `/cost` — show token usage totals
- `/resume <session-path>` — load a saved session into the REPL - `/resume <session-id-or-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions - `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL

View File

@@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec { SlashCommandSpec {
name: "resume", name: "resume",
summary: "Load a saved session into the REPL", summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"), argument_hint: Some("<session-id-or-path>"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec { SlashCommandSpec {
@@ -129,6 +129,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"), argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec {
name: "sessions",
summary: "List recent managed local sessions",
argument_hint: None,
resume_supported: false,
},
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -163,6 +169,7 @@ pub enum SlashCommand {
action: Option<String>, action: Option<String>,
target: Option<String>, target: Option<String>,
}, },
Sessions,
Unknown(String), Unknown(String),
} }
@@ -207,6 +214,7 @@ impl SlashCommand {
action: parts.next().map(ToOwned::to_owned), action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned),
}, },
"sessions" => Self::Sessions,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
}) })
} }
@@ -291,6 +299,7 @@ pub fn handle_slash_command(
| SlashCommand::Version | SlashCommand::Version
| SlashCommand::Export { .. } | SlashCommand::Export { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
} }
} }
@@ -365,6 +374,10 @@ mod tests {
target: Some("abc123".to_string()) target: Some("abc123".to_string())
}) })
); );
assert_eq!(
SlashCommand::parse("/sessions"),
Some(SlashCommand::Sessions)
);
} }
#[test] #[test]
@@ -378,7 +391,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
@@ -386,7 +399,8 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 15); assert!(help.contains("/sessions"));
assert_eq!(slash_command_specs().len(), 16);
assert_eq!(resume_supported_slash_commands().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 11);
} }
@@ -404,6 +418,7 @@ mod tests {
text: "recent".to_string(), text: "recent".to_string(),
}]), }]),
], ],
metadata: None,
}; };
let result = handle_slash_command( let result = handle_slash_command(
@@ -468,5 +483,6 @@ mod tests {
assert!( assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
); );
assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
} }
} }

View File

@@ -105,6 +105,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
compacted_session: Session { compacted_session: Session {
version: session.version, version: session.version,
messages: compacted_messages, messages: compacted_messages,
metadata: session.metadata.clone(),
}, },
removed_message_count: removed.len(), removed_message_count: removed.len(),
} }
@@ -393,6 +394,7 @@ mod tests {
let session = Session { let session = Session {
version: 1, version: 1,
messages: vec![ConversationMessage::user_text("hello")], messages: vec![ConversationMessage::user_text("hello")],
metadata: None,
}; };
let result = compact_session(&session, CompactionConfig::default()); let result = compact_session(&session, CompactionConfig::default());
@@ -420,6 +422,7 @@ mod tests {
usage: None, usage: None,
}, },
], ],
metadata: None,
}; };
let result = compact_session( let result = compact_session(

View File

@@ -73,7 +73,9 @@ pub use remote::{
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
}; };
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; pub use session::{
ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata,
};
pub use usage::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
}; };

View File

@@ -39,10 +39,19 @@ pub struct ConversationMessage {
pub usage: Option<TokenUsage>, pub usage: Option<TokenUsage>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetadata {
pub started_at: String,
pub model: String,
pub message_count: u32,
pub last_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session { pub struct Session {
pub version: u32, pub version: u32,
pub messages: Vec<ConversationMessage>, pub messages: Vec<ConversationMessage>,
pub metadata: Option<SessionMetadata>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -82,6 +91,7 @@ impl Session {
Self { Self {
version: 1, version: 1,
messages: Vec::new(), messages: Vec::new(),
metadata: None,
} }
} }
@@ -111,6 +121,9 @@ impl Session {
.collect(), .collect(),
), ),
); );
if let Some(metadata) = &self.metadata {
object.insert("metadata".to_string(), metadata.to_json());
}
JsonValue::Object(object) JsonValue::Object(object)
} }
@@ -131,7 +144,15 @@ impl Session {
.iter() .iter()
.map(ConversationMessage::from_json) .map(ConversationMessage::from_json)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(Self { version, messages }) let metadata = object
.get("metadata")
.map(SessionMetadata::from_json)
.transpose()?;
Ok(Self {
version,
messages,
metadata,
})
} }
} }
@@ -141,6 +162,41 @@ impl Default for Session {
} }
} }
impl SessionMetadata {
#[must_use]
pub fn to_json(&self) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
"started_at".to_string(),
JsonValue::String(self.started_at.clone()),
);
object.insert("model".to_string(), JsonValue::String(self.model.clone()));
object.insert(
"message_count".to_string(),
JsonValue::Number(i64::from(self.message_count)),
);
if let Some(last_prompt) = &self.last_prompt {
object.insert(
"last_prompt".to_string(),
JsonValue::String(last_prompt.clone()),
);
}
JsonValue::Object(object)
}
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
let object = value.as_object().ok_or_else(|| {
SessionError::Format("session metadata must be an object".to_string())
})?;
Ok(Self {
started_at: required_string(object, "started_at")?,
model: required_string(object, "model")?,
message_count: required_u32(object, "message_count")?,
last_prompt: optional_string(object, "last_prompt"),
})
}
}
impl ConversationMessage { impl ConversationMessage {
#[must_use] #[must_use]
pub fn user_text(text: impl Into<String>) -> Self { pub fn user_text(text: impl Into<String>) -> Self {
@@ -368,6 +424,13 @@ fn required_string(
.ok_or_else(|| SessionError::Format(format!("missing {key}"))) .ok_or_else(|| SessionError::Format(format!("missing {key}")))
} }
fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
object
.get(key)
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> { fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
let value = object let value = object
.get(key) .get(key)
@@ -378,7 +441,8 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ContentBlock, ConversationMessage, MessageRole, Session}; use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
use crate::json::JsonValue;
use crate::usage::TokenUsage; use crate::usage::TokenUsage;
use std::fs; use std::fs;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -386,6 +450,12 @@ mod tests {
#[test] #[test]
fn persists_and_restores_session_json() { fn persists_and_restores_session_json() {
let mut session = Session::new(); let mut session = Session::new();
session.metadata = Some(SessionMetadata {
started_at: "2026-04-01T00:00:00Z".to_string(),
model: "claude-sonnet".to_string(),
message_count: 3,
last_prompt: Some("hello".to_string()),
});
session session
.messages .messages
.push(ConversationMessage::user_text("hello")); .push(ConversationMessage::user_text("hello"));
@@ -428,5 +498,23 @@ mod tests {
restored.messages[1].usage.expect("usage").total_tokens(), restored.messages[1].usage.expect("usage").total_tokens(),
17 17
); );
assert_eq!(restored.metadata, session.metadata);
}
#[test]
fn loads_legacy_session_without_metadata() {
let legacy = r#"{
"version": 1,
"messages": [
{
"role": "user",
"blocks": [{"type": "text", "text": "hello"}]
}
]
}"#;
let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
.expect("legacy session should parse");
assert_eq!(restored.messages.len(), 1);
assert!(restored.metadata.is_none());
} }
} }

View File

@@ -300,6 +300,7 @@ mod tests {
cache_read_input_tokens: 0, cache_read_input_tokens: 0,
}), }),
}], }],
metadata: None,
}; };
let tracker = UsageTracker::from_session(&session); let tracker = UsageTracker::from_session(&session);

View File

@@ -27,7 +27,7 @@ use runtime::{
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde_json::json; use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
@@ -37,6 +37,7 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -535,7 +536,14 @@ fn print_version() {
} }
fn resume_session(session_path: &Path, commands: &[String]) { fn resume_session(session_path: &Path, commands: &[String]) {
let session = match Session::load_from_path(session_path) { let handle = match resolve_session_reference(&session_path.display().to_string()) {
Ok(handle) => handle,
Err(error) => {
eprintln!("failed to resolve session: {error}");
std::process::exit(1);
}
};
let session = match Session::load_from_path(&handle.path) {
Ok(session) => session, Ok(session) => session,
Err(error) => { Err(error) => {
eprintln!("failed to restore session: {error}"); eprintln!("failed to restore session: {error}");
@@ -546,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
if commands.is_empty() { if commands.is_empty() {
println!( println!(
"Restored session from {} ({} messages).", "Restored session from {} ({} messages).",
session_path.display(), handle.path.display(),
session.messages.len() session.messages.len()
); );
return; return;
@@ -558,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
eprintln!("unsupported resumed command: {raw_command}"); eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2); std::process::exit(2);
}; };
match run_resume_command(session_path, &session, &command) { match run_resume_command(&handle.path, &session, &command) {
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: next_session, session: next_session,
message, message,
@@ -883,6 +891,7 @@ fn run_resume_command(
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -939,6 +948,9 @@ struct ManagedSessionSummary {
path: PathBuf, path: PathBuf,
modified_epoch_secs: u64, modified_epoch_secs: u64,
message_count: usize, message_count: usize,
model: Option<String>,
started_at: Option<String>,
last_prompt: Option<String>,
} }
struct LiveCli { struct LiveCli {
@@ -959,6 +971,7 @@ impl LiveCli {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
auto_compact_inactive_sessions(&session.id)?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
@@ -1130,6 +1143,10 @@ impl LiveCli {
SlashCommand::Session { action, target } => { SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())? self.handle_session_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Sessions => {
println!("{}", render_session_list(&self.session.id)?);
false
}
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("unknown slash command: /{name}");
false false
@@ -1138,7 +1155,10 @@ impl LiveCli {
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> { fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?; let mut session = self.runtime.session().clone();
session.metadata = Some(derive_session_metadata(&session, &self.model));
session.save_to_path(&self.session.path)?;
auto_compact_inactive_sessions(&self.session.id)?;
Ok(()) Ok(())
} }
@@ -1283,13 +1303,20 @@ impl LiveCli {
session_path: Option<String>, session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("Usage: /resume <session-id-or-path>");
return Ok(false); return Ok(false);
}; };
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1366,6 +1393,13 @@ impl LiveCli {
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1410,8 +1444,10 @@ impl LiveCli {
} }
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let home = env::var_os("HOME")
let path = cwd.join(".claude").join("sessions"); .map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
let path = home.join(".claude").join("sessions");
fs::create_dir_all(&path)?; fs::create_dir_all(&path)?;
Ok(path) Ok(path)
} }
@@ -1432,8 +1468,19 @@ fn generate_session_id() -> String {
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference); let direct = PathBuf::from(reference);
let expanded = if let Some(stripped) = reference.strip_prefix("~/") {
sessions_dir()?
.parent()
.and_then(|claude| claude.parent())
.map(|home| home.join(stripped))
.unwrap_or(direct.clone())
} else {
direct.clone()
};
let path = if direct.exists() { let path = if direct.exists() {
direct direct
} else if expanded.exists() {
expanded
} else { } else {
sessions_dir()?.join(format!("{reference}.json")) sessions_dir()?.join(format!("{reference}.json"))
}; };
@@ -1463,9 +1510,11 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or_default(); .unwrap_or_default();
let message_count = Session::load_from_path(&path) let session = Session::load_from_path(&path).ok();
.map(|session| session.messages.len()) let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
.unwrap_or_default(); let stored = session
.as_ref()
.and_then(|session| session.metadata.as_ref());
let id = path let id = path
.file_stem() .file_stem()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
@@ -1475,7 +1524,12 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id, id,
path, path,
modified_epoch_secs, modified_epoch_secs,
message_count, message_count: stored.map_or(derived_message_count, |metadata| {
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
}); });
} }
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1498,17 +1552,99 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else { } else {
"○ saved" "○ saved"
}; };
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!( lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", " {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs, modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(), path = session.path.display(),
)); ));
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn current_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
fn current_timestamp_rfc3339ish() -> String {
format!("{}Z", current_epoch_secs())
}
fn last_prompt_from_session(session: &Session) -> Option<String> {
session
.messages
.iter()
.rev()
.find(|message| message.role == MessageRole::User)
.and_then(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.filter(|text| !text.is_empty())
}
fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata {
let started_at = session
.metadata
.as_ref()
.map_or_else(current_timestamp_rfc3339ish, |metadata| {
metadata.started_at.clone()
});
SessionMetadata {
started_at,
model: model.to_string(),
message_count: session.messages.len().try_into().unwrap_or(u32::MAX),
last_prompt: last_prompt_from_session(session),
}
}
fn session_age_secs(modified_epoch_secs: u64) -> u64 {
current_epoch_secs().saturating_sub(modified_epoch_secs)
}
fn auto_compact_inactive_sessions(
active_session_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
for summary in list_managed_sessions()? {
if summary.id == active_session_id
|| session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS
{
continue;
}
let path = summary.path.clone();
let Ok(session) = Session::load_from_path(&path) else {
continue;
};
if !runtime::should_compact(&session, CompactionConfig::default()) {
continue;
}
let mut compacted =
runtime::compact_session(&session, CompactionConfig::default()).compacted_session;
let model = compacted.metadata.as_ref().map_or_else(
|| DEFAULT_MODEL.to_string(),
|metadata| metadata.model.clone(),
);
compacted.metadata = Some(derive_session_metadata(&compacted, &model));
compacted.save_to_path(&path)?;
}
Ok(())
}
fn render_repl_help() -> String { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -1534,7 +1670,6 @@ fn status_context(
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len(); let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?; let runtime_config = loader.load()?;
let discovered_config_files = discovered_config_files.max(runtime_config.loaded_entries().len());
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) = let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref()); parse_git_status_metadata(project_context.git_status.as_deref());
@@ -2390,17 +2525,73 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
filter_tool_specs, format_compact_report, format_cost_report, format_init_report, derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report,
format_model_report, format_model_switch_report, format_permissions_report, format_init_report, format_model_report, format_model_switch_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_report, format_permissions_switch_report, format_resume_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions,
parse_git_status_metadata, render_config_report, render_init_claude_md, normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, render_init_claude_md, render_memory_report, render_repl_help,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat,
SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test]
fn derive_session_metadata_recomputes_prompt_and_count() {
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("first prompt"));
session
.messages
.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "reply".to_string(),
}]));
let metadata = derive_session_metadata(&session, "claude-test");
assert_eq!(metadata.model, "claude-test");
assert_eq!(metadata.message_count, 2);
assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt"));
assert!(metadata.started_at.ends_with('Z'));
}
#[test]
fn managed_sessions_use_home_directory_and_list_metadata() {
let temp =
std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).expect("temp home should exist");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", &temp);
let dir = sessions_dir().expect("sessions dir");
assert_eq!(dir, temp.join(".claude").join("sessions"));
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("persist me"));
session.metadata = Some(derive_session_metadata(&session, "claude-home"));
let file = dir.join("session-test.json");
session.save_to_path(&file).expect("session save");
let listed = list_managed_sessions().expect("session list");
let found = listed
.into_iter()
.find(|entry| entry.id == "session-test")
.expect("saved session should be listed");
assert_eq!(found.message_count, 1);
assert_eq!(found.model.as_deref(), Some("claude-home"));
assert_eq!(found.last_prompt.as_deref(), Some("persist me"));
fs::remove_file(file).ok();
if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
}
fs::remove_dir_all(temp).ok();
}
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
assert_eq!( assert_eq!(
@@ -2606,7 +2797,8 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));

View File

@@ -6,12 +6,10 @@ license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
api = { path = "../api" }
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -3,17 +3,10 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use api::{
resolve_startup_auth_source, AnthropicClient, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use runtime::{ use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, GrepSearchInput, PermissionMode,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -241,8 +234,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
}, },
ToolSpec { ToolSpec {
name: "Agent", name: "Agent",
description: description: "Launch a specialized agent task and persist its handoff metadata.",
"Launch and execute a specialized child agent conversation with bounded recursion.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -250,8 +242,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"prompt": { "type": "string" }, "prompt": { "type": "string" },
"subagent_type": { "type": "string" }, "subagent_type": { "type": "string" },
"name": { "type": "string" }, "name": { "type": "string" },
"model": { "type": "string" }, "model": { "type": "string" }
"max_depth": { "type": "integer", "minimum": 0 }
}, },
"required": ["description", "prompt"], "required": ["description", "prompt"],
"additionalProperties": false "additionalProperties": false
@@ -588,7 +579,6 @@ struct AgentInput {
subagent_type: Option<String>, subagent_type: Option<String>,
name: Option<String>, name: Option<String>,
model: Option<String>, model: Option<String>,
max_depth: Option<usize>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -722,16 +712,6 @@ struct AgentOutput {
subagent_type: Option<String>, subagent_type: Option<String>,
model: Option<String>, model: Option<String>,
status: String, status: String,
#[serde(rename = "maxDepth")]
max_depth: usize,
#[serde(rename = "depth")]
depth: usize,
#[serde(rename = "result")]
result: Option<String>,
#[serde(rename = "assistantMessages")]
assistant_messages: Vec<String>,
#[serde(rename = "toolResults")]
tool_results: Vec<AgentToolResult>,
#[serde(rename = "outputFile")] #[serde(rename = "outputFile")]
output_file: String, output_file: String,
#[serde(rename = "manifestFile")] #[serde(rename = "manifestFile")]
@@ -740,15 +720,6 @@ struct AgentOutput {
created_at: String, created_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AgentToolResult {
#[serde(rename = "toolName")]
tool_name: String,
output: String,
#[serde(rename = "isError")]
is_error: bool,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ToolSearchOutput { struct ToolSearchOutput {
matches: Vec<String>, matches: Vec<String>,
@@ -1360,14 +1331,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
return Err(String::from("prompt must not be empty")); return Err(String::from("prompt must not be empty"));
} }
let depth = current_agent_depth()?;
let max_depth = input.max_depth.unwrap_or(3);
if depth >= max_depth {
return Err(format!(
"Agent max_depth exceeded: current depth {depth} reached limit {max_depth}"
));
}
let agent_id = make_agent_id(); let agent_id = make_agent_id();
let output_dir = agent_store_dir()?; let output_dir = agent_store_dir()?;
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
@@ -1381,31 +1344,35 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
.filter(|name| !name.is_empty()) .filter(|name| !name.is_empty())
.unwrap_or_else(|| slugify_agent_name(&input.description)); .unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now(); let created_at = iso8601_now();
let model = input.model.clone().or_else(agent_default_model);
let child_result = with_agent_depth(depth + 1, || { let output_contents = format!(
run_child_agent_conversation(&input.prompt, model.clone(), max_depth) "# Agent Task
})?;
- id: {}
- name: {}
- description: {}
- subagent_type: {}
- created_at: {}
## Prompt
{}
",
agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
);
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
let manifest = AgentOutput { let manifest = AgentOutput {
agent_id, agent_id,
name: agent_name, name: agent_name,
description: input.description, description: input.description,
subagent_type: Some(normalized_subagent_type), subagent_type: Some(normalized_subagent_type),
model, model: input.model,
status: String::from("completed"), status: String::from("queued"),
max_depth,
depth,
result: child_result.result.clone(),
assistant_messages: child_result.assistant_messages.clone(),
tool_results: child_result.tool_results.clone(),
output_file: output_file.display().to_string(), output_file: output_file.display().to_string(),
manifest_file: manifest_file.display().to_string(), manifest_file: manifest_file.display().to_string(),
created_at, created_at,
}; };
let output_contents = render_agent_output(&manifest);
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
std::fs::write( std::fs::write(
&manifest_file, &manifest_file,
serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
@@ -1415,461 +1382,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
Ok(manifest) Ok(manifest)
} }
#[derive(Debug, Clone)]
struct ChildConversationResult {
result: Option<String>,
assistant_messages: Vec<String>,
tool_results: Vec<AgentToolResult>,
}
fn run_child_agent_conversation(
prompt: &str,
model: Option<String>,
_max_depth: usize,
) -> Result<ChildConversationResult, String> {
let mut runtime = ConversationRuntime::new(
Session::new(),
build_agent_api_client(model.unwrap_or_else(default_agent_model))?,
AgentToolExecutor,
agent_permission_policy(),
build_agent_system_prompt()?,
)
.with_max_iterations(16);
let summary = runtime
.run_turn(prompt, None)
.map_err(|error| error.to_string())?;
let assistant_messages = summary
.assistant_messages
.iter()
.filter_map(extract_message_text)
.collect::<Vec<_>>();
let tool_results = summary
.tool_results
.iter()
.filter_map(extract_agent_tool_result)
.collect::<Vec<_>>();
let result = assistant_messages.last().cloned();
Ok(ChildConversationResult {
result,
assistant_messages,
tool_results,
})
}
fn render_agent_output(output: &AgentOutput) -> String {
let mut lines = vec![
"# Agent Task".to_string(),
String::new(),
format!("- id: {}", output.agent_id),
format!("- name: {}", output.name),
format!("- description: {}", output.description),
format!(
"- subagent_type: {}",
output.subagent_type.as_deref().unwrap_or("general-purpose")
),
format!("- status: {}", output.status),
format!("- depth: {}", output.depth),
format!("- max_depth: {}", output.max_depth),
format!("- created_at: {}", output.created_at),
String::new(),
"## Result".to_string(),
String::new(),
output
.result
.clone()
.unwrap_or_else(|| String::from("<no final assistant text>")),
];
if !output.tool_results.is_empty() {
lines.push(String::new());
lines.push("## Tool Results".to_string());
lines.push(String::new());
lines.extend(output.tool_results.iter().map(|result| {
format!(
"- {} [{}]: {}",
result.tool_name,
if result.is_error { "error" } else { "ok" },
result.output
)
}));
}
lines.join("\n")
}
fn current_agent_depth() -> Result<usize, String> {
std::env::var("CLAWD_AGENT_DEPTH")
.ok()
.map(|value| {
value
.parse::<usize>()
.map_err(|error| format!("invalid CLAWD_AGENT_DEPTH: {error}"))
})
.transpose()
.map(|value| value.unwrap_or(0))
}
fn with_agent_depth<T>(depth: usize, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let previous = std::env::var("CLAWD_AGENT_DEPTH").ok();
std::env::set_var("CLAWD_AGENT_DEPTH", depth.to_string());
let result = f();
if let Some(previous) = previous {
std::env::set_var("CLAWD_AGENT_DEPTH", previous);
} else {
std::env::remove_var("CLAWD_AGENT_DEPTH");
}
result
}
fn agent_default_model() -> Option<String> {
std::env::var("CLAWD_MODEL")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn default_agent_model() -> String {
agent_default_model().unwrap_or_else(|| String::from("claude-sonnet-4-20250514"))
}
fn build_agent_system_prompt() -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let date = std::env::var("CLAWD_CURRENT_DATE").unwrap_or_else(|_| String::from("2026-04-01"));
load_system_prompt(cwd, &date, std::env::consts::OS, "unknown")
.map_err(|error| error.to_string())
}
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
)
}
struct AgentToolExecutor;
impl ToolExecutor for AgentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
execute_tool(tool_name, &value).map_err(ToolError::new)
}
}
enum AgentApiClient {
Scripted(ScriptedAgentApiClient),
Anthropic(AnthropicAgentApiClient),
}
impl ApiClient for AgentApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
match self {
Self::Scripted(client) => client.stream(request),
Self::Anthropic(client) => client.stream(request),
}
}
}
fn build_agent_api_client(model: String) -> Result<AgentApiClient, String> {
if let Some(script) = std::env::var("CLAWD_AGENT_TEST_SCRIPT")
.ok()
.filter(|value| !value.trim().is_empty())
{
return Ok(AgentApiClient::Scripted(ScriptedAgentApiClient::new(
&script,
)?));
}
Ok(AgentApiClient::Anthropic(AnthropicAgentApiClient::new(
model,
)?))
}
struct AnthropicAgentApiClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
model: String,
}
impl AnthropicAgentApiClient {
fn new(model: String) -> Result<Self, String> {
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
client: AnthropicClient::from_auth(resolve_agent_auth_source()?),
model,
})
}
}
impl ApiClient for AnthropicAgentApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32,
messages: convert_agent_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| {
request.system_prompt.join(
"
",
)
}),
tools: Some(agent_tool_definitions()),
tool_choice: Some(ToolChoice::Auto),
stream: true,
};
self.runtime.block_on(async {
let mut stream = self
.client
.stream_message(&message_request)
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false;
while let Some(event) = stream
.next_event()
.await
.map_err(|error| RuntimeError::new(error.to_string()))?
{
match event {
ApiStreamEvent::MessageStart(start) => {
push_agent_output_blocks(
start.message.content,
&mut events,
&mut pending_tool,
);
}
ApiStreamEvent::ContentBlockStart(start) => {
push_agent_output_block(
start.content_block,
&mut events,
&mut pending_tool,
);
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: delta.usage.cache_creation_input_tokens,
cache_read_input_tokens: delta.usage.cache_read_input_tokens,
}));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
events.push(AssistantEvent::MessageStop);
}
}
}
if !saw_stop {
events.push(AssistantEvent::MessageStop);
}
Ok(events)
})
}
}
fn resolve_agent_auth_source() -> Result<api::AuthSource, String> {
resolve_startup_auth_source(|| {
let cwd = std::env::current_dir().map_err(api::ApiError::from)?;
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
})
.map_err(|error| error.to_string())
}
fn agent_tool_definitions() -> Vec<ToolDefinition> {
mvp_tool_specs()
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect()
}
fn convert_agent_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}
fn push_agent_output_blocks(
blocks: Vec<OutputContentBlock>,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
) {
for block in blocks {
push_agent_output_block(block, events, pending_tool);
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
}
fn push_agent_output_block(
block: OutputContentBlock,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
) {
match block {
OutputContentBlock::Text { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
*pending_tool = Some((id, name, input.to_string()));
}
}
}
#[derive(Debug)]
struct ScriptedAgentApiClient {
turns: Vec<Vec<ScriptedAgentEvent>>,
call_count: usize,
}
impl ScriptedAgentApiClient {
fn new(script: &str) -> Result<Self, String> {
let turns = serde_json::from_str(script).map_err(|error| error.to_string())?;
Ok(Self {
turns,
call_count: 0,
})
}
}
impl ApiClient for ScriptedAgentApiClient {
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if self.call_count >= self.turns.len() {
return Err(RuntimeError::new("scripted agent client exhausted"));
}
let events = self.turns[self.call_count]
.iter()
.map(ScriptedAgentEvent::to_runtime_event)
.chain(std::iter::once(AssistantEvent::MessageStop))
.collect();
self.call_count += 1;
Ok(events)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ScriptedAgentEvent {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
}
impl ScriptedAgentEvent {
fn to_runtime_event(&self) -> AssistantEvent {
match self {
Self::Text { text } => AssistantEvent::TextDelta(text.clone()),
Self::ToolUse { id, name, input } => AssistantEvent::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.to_string(),
},
}
}
}
fn extract_message_text(message: &ConversationMessage) -> Option<String> {
let text = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<String>();
(!text.is_empty()).then_some(text)
}
fn extract_agent_tool_result(message: &ConversationMessage) -> Option<AgentToolResult> {
message.blocks.iter().find_map(|block| match block {
ContentBlock::ToolResult {
tool_name,
output,
is_error,
..
} => Some(AgentToolResult {
tool_name: tool_name.clone(),
output: output.clone(),
is_error: *is_error,
}),
_ => None,
})
}
#[allow(clippy::needless_pass_by_value)] #[allow(clippy::needless_pass_by_value)]
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
let deferred = deferred_tool_specs(); let deferred = deferred_tool_specs();
@@ -3251,28 +2763,12 @@ mod tests {
} }
#[test] #[test]
fn agent_executes_child_conversation_and_persists_results() { fn agent_persists_handoff_metadata() {
let _guard = env_lock() let _guard = env_lock()
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner); .unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_path("agent-store"); let dir = temp_path("agent-store");
std::env::set_var("CLAWD_AGENT_STORE", &dir); std::env::set_var("CLAWD_AGENT_STORE", &dir);
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![
vec![json!({
"type": "tool_use",
"id": "tool-1",
"name": "StructuredOutput",
"input": {"ok": true, "items": [1, 2, 3]}
})],
vec![json!({
"type": "text",
"text": "Child agent completed successfully."
})],
])
.expect("script json"),
);
let result = execute_tool( let result = execute_tool(
"Agent", "Agent",
@@ -3284,35 +2780,22 @@ mod tests {
}), }),
) )
.expect("Agent should succeed"); .expect("Agent should succeed");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
std::env::remove_var("CLAWD_AGENT_STORE"); std::env::remove_var("CLAWD_AGENT_STORE");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert_eq!(output["name"], "ship-audit"); assert_eq!(output["name"], "ship-audit");
assert_eq!(output["subagentType"], "Explore"); assert_eq!(output["subagentType"], "Explore");
assert_eq!(output["status"], "completed"); assert_eq!(output["status"], "queued");
assert_eq!(output["depth"], 0); assert!(output["createdAt"].as_str().is_some());
assert_eq!(output["maxDepth"], 3);
assert_eq!(output["result"], "Child agent completed successfully.");
assert_eq!(output["toolResults"][0]["toolName"], "StructuredOutput");
assert_eq!(output["toolResults"][0]["isError"], false);
let manifest_file = output["manifestFile"].as_str().expect("manifest file"); let manifest_file = output["manifestFile"].as_str().expect("manifest file");
let output_file = output["outputFile"].as_str().expect("output file"); let output_file = output["outputFile"].as_str().expect("output file");
let contents = std::fs::read_to_string(output_file).expect("agent file exists"); let contents = std::fs::read_to_string(output_file).expect("agent file exists");
let manifest_contents = let manifest_contents =
std::fs::read_to_string(manifest_file).expect("manifest file exists"); std::fs::read_to_string(manifest_file).expect("manifest file exists");
assert!(contents.contains("Child agent completed successfully.")); assert!(contents.contains("Audit the branch"));
assert!(contents.contains("StructuredOutput [ok]")); assert!(contents.contains("Check tests and outstanding work."));
assert!(manifest_contents.contains("\"subagentType\": \"Explore\"")); assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![vec![json!({
"type": "text",
"text": "Normalized alias check."
})]])
.expect("script json"),
);
let normalized = execute_tool( let normalized = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3322,19 +2805,10 @@ mod tests {
}), }),
) )
.expect("Agent should normalize built-in aliases"); .expect("Agent should normalize built-in aliases");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
let normalized_output: serde_json::Value = let normalized_output: serde_json::Value =
serde_json::from_str(&normalized).expect("valid json"); serde_json::from_str(&normalized).expect("valid json");
assert_eq!(normalized_output["subagentType"], "Explore"); assert_eq!(normalized_output["subagentType"], "Explore");
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![vec![json!({
"type": "text",
"text": "Name normalization check."
})]])
.expect("script json"),
);
let named = execute_tool( let named = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3344,14 +2818,13 @@ mod tests {
}), }),
) )
.expect("Agent should normalize explicit names"); .expect("Agent should normalize explicit names");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json"); let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json");
assert_eq!(named_output["name"], "ship-audit"); assert_eq!(named_output["name"], "ship-audit");
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
#[test] #[test]
fn agent_rejects_blank_required_fields_and_enforces_max_depth() { fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool( let missing_description = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3371,22 +2844,6 @@ mod tests {
) )
.expect_err("blank prompt should fail"); .expect_err("blank prompt should fail");
assert!(missing_prompt.contains("prompt must not be empty")); assert!(missing_prompt.contains("prompt must not be empty"));
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::env::set_var("CLAWD_AGENT_DEPTH", "1");
let depth_error = execute_tool(
"Agent",
&json!({
"description": "Nested agent",
"prompt": "Do nested work.",
"max_depth": 1
}),
)
.expect_err("max depth should fail");
std::env::remove_var("CLAWD_AGENT_DEPTH");
assert!(depth_error.contains("max_depth exceeded"));
} }
#[test] #[test]