mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 00:24:50 +08:00
Recover the MCP lane on top of current main
This resolves the stale-branch merge against origin/main, keeps the MCP runtime wiring, and preserves prompt-approved CLI tool execution after the mock parity harness additions landed upstream. Constraint: Branch had to absorb origin/main changes through a contentful merge before more MCP work Constraint: Prompt-approved runtime tool execution must continue working with new CLI/mock parity coverage Rejected: Keep permission enforcer attached inside CliToolExecutor for conversation turns | caused prompt-approved bash parity flow to fail as a tool error Rejected: Defer the merge and continue on stale history | would leave the lane red against current main Confidence: high Scope-risk: moderate Reversibility: clean Directive: Runtime permission policy and executor-side permission enforcement are separate layers; do not reapply executor enforcement to conversation turns without revalidating mock parity harness approval flows Tested: cargo test -p rusty-claude-cli --test mock_parity_harness -- --nocapture; cargo test -p rusty-claude-cli -- --nocapture; cargo test --workspace -- --nocapture Not-tested: Additional live remote/provider scenarios beyond the existing workspace suite
This commit is contained in:
@@ -26,3 +26,8 @@ tools = { path = "../tools" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
@@ -30,9 +30,9 @@ use api::{
|
||||
};
|
||||
|
||||
use commands::{
|
||||
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||
validate_slash_command_input, SlashCommand,
|
||||
handle_agents_slash_command, handle_mcp_slash_command, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, SlashCommand,
|
||||
};
|
||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||
use init::initialize_repo;
|
||||
@@ -40,12 +40,13 @@ use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
use runtime::{
|
||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||
parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient,
|
||||
ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||
ConversationMessage, ConversationRuntime, McpServerManager, McpTool, MessageRole,
|
||||
OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, RuntimeError, Session, TokenUsage,
|
||||
ToolError, ToolExecutor, UsageTracker,
|
||||
parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials,
|
||||
ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
|
||||
ContentBlock, ConversationMessage, ConversationRuntime, McpServerManager, McpTool,
|
||||
MessageRole, ModelPricing, OAuthAuthorizationRequest, OAuthConfig,
|
||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext,
|
||||
PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError,
|
||||
ToolExecutor, UsageTracker, format_usd, pricing_for_model,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -109,6 +110,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
CliAction::DumpManifests => dump_manifests(),
|
||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
||||
CliAction::Mcp { args } => LiveCli::print_mcp(args.as_deref())?,
|
||||
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
|
||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||
CliAction::Version => print_version(),
|
||||
@@ -149,6 +151,9 @@ enum CliAction {
|
||||
Agents {
|
||||
args: Option<String>,
|
||||
},
|
||||
Mcp {
|
||||
args: Option<String>,
|
||||
},
|
||||
Skills {
|
||||
args: Option<String>,
|
||||
},
|
||||
@@ -344,6 +349,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
"agents" => Ok(CliAction::Agents {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
}),
|
||||
"mcp" => Ok(CliAction::Mcp {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
}),
|
||||
"skills" => Ok(CliAction::Skills {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
}),
|
||||
@@ -402,6 +410,7 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
||||
"dump-manifests"
|
||||
| "bootstrap-plan"
|
||||
| "agents"
|
||||
| "mcp"
|
||||
| "skills"
|
||||
| "system-prompt"
|
||||
| "login"
|
||||
@@ -437,6 +446,14 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||
match SlashCommand::parse(&raw) {
|
||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
||||
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
||||
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
|
||||
args: match (action, target) {
|
||||
(None, None) => None,
|
||||
(Some(action), None) => Some(action),
|
||||
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||
(None, Some(target)) => Some(target),
|
||||
},
|
||||
}),
|
||||
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
|
||||
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
|
||||
Ok(Some(command)) => Err({
|
||||
@@ -610,12 +627,32 @@ fn permission_mode_from_label(mode: &str) -> PermissionMode {
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode {
|
||||
match mode {
|
||||
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
|
||||
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_permission_mode() -> PermissionMode {
|
||||
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(normalize_permission_mode)
|
||||
.map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
|
||||
.map(permission_mode_from_label)
|
||||
.or_else(config_permission_mode_for_current_dir)
|
||||
.unwrap_or(PermissionMode::DangerFullAccess)
|
||||
}
|
||||
|
||||
fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
loader
|
||||
.load()
|
||||
.ok()?
|
||||
.permission_mode()
|
||||
.map(permission_mode_from_resolved)
|
||||
}
|
||||
|
||||
fn filter_tool_specs(
|
||||
@@ -1309,12 +1346,17 @@ fn run_resume_command(
|
||||
),
|
||||
});
|
||||
}
|
||||
let backup_path = write_session_clear_backup(session, session_path)?;
|
||||
let previous_session_id = session.session_id.clone();
|
||||
let cleared = Session::new();
|
||||
let new_session_id = cleared.session_id.clone();
|
||||
cleared.save_to_path(session_path)?;
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: cleared,
|
||||
message: Some(format!(
|
||||
"Cleared resumed session file {}.",
|
||||
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
|
||||
backup_path.display(),
|
||||
backup_path.display(),
|
||||
session_path.display()
|
||||
)),
|
||||
})
|
||||
@@ -1361,6 +1403,19 @@ fn run_resume_command(
|
||||
session: session.clone(),
|
||||
message: Some(render_config_report(section.as_deref())?),
|
||||
}),
|
||||
SlashCommand::Mcp { action, target } => {
|
||||
let cwd = env::current_dir()?;
|
||||
let args = match (action.as_deref(), target.as_deref()) {
|
||||
(None, None) => None,
|
||||
(Some(action), None) => Some(action.to_string()),
|
||||
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||
(None, Some(target)) => Some(target.to_string()),
|
||||
};
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
|
||||
})
|
||||
}
|
||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_memory_report()?),
|
||||
@@ -1417,7 +1472,47 @@ fn run_resume_command(
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. } => Err("unsupported resumed slash command".into()),
|
||||
| SlashCommand::Plugins { .. }
|
||||
| SlashCommand::Doctor
|
||||
| SlashCommand::Login
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::Upgrade
|
||||
| SlashCommand::Stats
|
||||
| SlashCommand::Share
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Files
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Exit
|
||||
| SlashCommand::Summary
|
||||
| SlashCommand::Desktop
|
||||
| SlashCommand::Brief
|
||||
| SlashCommand::Advisor
|
||||
| SlashCommand::Stickers
|
||||
| SlashCommand::Insights
|
||||
| SlashCommand::Thinkback
|
||||
| SlashCommand::ReleaseNotes
|
||||
| SlashCommand::SecurityReview
|
||||
| SlashCommand::Keybindings
|
||||
| SlashCommand::PrivacySettings
|
||||
| SlashCommand::Plan { .. }
|
||||
| SlashCommand::Review { .. }
|
||||
| SlashCommand::Tasks { .. }
|
||||
| SlashCommand::Theme { .. }
|
||||
| SlashCommand::Voice { .. }
|
||||
| SlashCommand::Usage { .. }
|
||||
| SlashCommand::Rename { .. }
|
||||
| SlashCommand::Copy { .. }
|
||||
| SlashCommand::Hooks { .. }
|
||||
| SlashCommand::Context { .. }
|
||||
| SlashCommand::Color { .. }
|
||||
| SlashCommand::Effort { .. }
|
||||
| SlashCommand::Branch { .. }
|
||||
| SlashCommand::Rewind { .. }
|
||||
| SlashCommand::Ide { .. }
|
||||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2110,12 +2205,19 @@ impl LiveCli {
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
}
|
||||
},
|
||||
"estimated_cost": format_usd(
|
||||
summary.usage.estimate_cost_usd_with_pricing(
|
||||
pricing_for_model(&self.model)
|
||||
.unwrap_or_else(runtime::ModelPricing::default_sonnet_tier)
|
||||
).total_cost_usd()
|
||||
)
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn handle_repl_command(
|
||||
&mut self,
|
||||
command: SlashCommand,
|
||||
@@ -2150,7 +2252,7 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Teleport { target } => {
|
||||
self.run_teleport(target.as_deref())?;
|
||||
Self::run_teleport(target.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::DebugToolCall => {
|
||||
@@ -2177,6 +2279,16 @@ impl LiveCli {
|
||||
Self::print_config(section.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Mcp { action, target } => {
|
||||
let args = match (action.as_deref(), target.as_deref()) {
|
||||
(None, None) => None,
|
||||
(Some(action), None) => Some(action.to_string()),
|
||||
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||
(None, Some(target)) => Some(target.to_string()),
|
||||
};
|
||||
Self::print_mcp(args.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Memory => {
|
||||
Self::print_memory()?;
|
||||
false
|
||||
@@ -2211,6 +2323,49 @@ impl LiveCli {
|
||||
Self::print_skills(args.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Doctor
|
||||
| SlashCommand::Login
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::Upgrade
|
||||
| SlashCommand::Stats
|
||||
| SlashCommand::Share
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Files
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Exit
|
||||
| SlashCommand::Summary
|
||||
| SlashCommand::Desktop
|
||||
| SlashCommand::Brief
|
||||
| SlashCommand::Advisor
|
||||
| SlashCommand::Stickers
|
||||
| SlashCommand::Insights
|
||||
| SlashCommand::Thinkback
|
||||
| SlashCommand::ReleaseNotes
|
||||
| SlashCommand::SecurityReview
|
||||
| SlashCommand::Keybindings
|
||||
| SlashCommand::PrivacySettings
|
||||
| SlashCommand::Plan { .. }
|
||||
| SlashCommand::Review { .. }
|
||||
| SlashCommand::Tasks { .. }
|
||||
| SlashCommand::Theme { .. }
|
||||
| SlashCommand::Voice { .. }
|
||||
| SlashCommand::Usage { .. }
|
||||
| SlashCommand::Rename { .. }
|
||||
| SlashCommand::Copy { .. }
|
||||
| SlashCommand::Hooks { .. }
|
||||
| SlashCommand::Context { .. }
|
||||
| SlashCommand::Color { .. }
|
||||
| SlashCommand::Effort { .. }
|
||||
| SlashCommand::Branch { .. }
|
||||
| SlashCommand::Rewind { .. }
|
||||
| SlashCommand::Ide { .. }
|
||||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. } => {
|
||||
eprintln!("Command registered but not yet implemented.");
|
||||
false
|
||||
}
|
||||
SlashCommand::Unknown(name) => {
|
||||
eprintln!("{}", format_unknown_slash_command(&name));
|
||||
false
|
||||
@@ -2358,6 +2513,7 @@ impl LiveCli {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let previous_session = self.session.clone();
|
||||
let session_state = Session::new();
|
||||
self.session = create_managed_session_handle(&session_state.session_id)?;
|
||||
let runtime = build_runtime(
|
||||
@@ -2373,10 +2529,13 @@ impl LiveCli {
|
||||
)?;
|
||||
self.replace_runtime(runtime)?;
|
||||
println!(
|
||||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||||
"Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}",
|
||||
previous_session.id,
|
||||
previous_session.id,
|
||||
self.model,
|
||||
self.permission_mode.as_str(),
|
||||
self.session.id,
|
||||
self.session.path.display(),
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
@@ -2442,6 +2601,12 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_mcp(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
println!("{}", handle_mcp_slash_command(args, &cwd)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
println!("{}", handle_skills_slash_command(args, &cwd)?);
|
||||
@@ -2655,8 +2820,7 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn run_teleport(target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
println!("Usage: /teleport <symbol-or-path>");
|
||||
return Ok(());
|
||||
@@ -2906,6 +3070,27 @@ fn format_session_modified_age(modified_epoch_millis: u128) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_session_clear_backup(
|
||||
session: &Session,
|
||||
session_path: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let backup_path = session_clear_backup_path(session_path);
|
||||
session.save_to_path(&backup_path)?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
fn session_clear_backup_path(session_path: &Path) -> PathBuf {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map_or(0, |duration| duration.as_millis());
|
||||
let file_name = session_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("session.jsonl");
|
||||
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
|
||||
}
|
||||
|
||||
fn render_repl_help() -> String {
|
||||
[
|
||||
"REPL".to_string(),
|
||||
@@ -3674,7 +3859,7 @@ fn build_runtime_plugin_state_with_loader(
|
||||
loader: &ConfigLoader,
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
|
||||
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||
let plugin_manager = build_plugin_manager(cwd, loader, runtime_config);
|
||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||
let plugin_hook_config =
|
||||
runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?);
|
||||
@@ -4114,6 +4299,8 @@ fn build_runtime_with_plugin_state(
|
||||
mcp_state,
|
||||
} = runtime_plugin_state;
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(
|
||||
@@ -4131,8 +4318,7 @@ fn build_runtime_with_plugin_state(
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
),
|
||||
permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?,
|
||||
policy,
|
||||
system_prompt,
|
||||
&feature_config,
|
||||
);
|
||||
@@ -4508,6 +4694,9 @@ fn slash_command_completion_candidates_with_sessions(
|
||||
"/config hooks",
|
||||
"/config model",
|
||||
"/config plugins",
|
||||
"/mcp ",
|
||||
"/mcp list",
|
||||
"/mcp show ",
|
||||
"/export ",
|
||||
"/issue ",
|
||||
"/model ",
|
||||
@@ -4533,6 +4722,7 @@ fn slash_command_completion_candidates_with_sessions(
|
||||
"/teleport ",
|
||||
"/ultraplan ",
|
||||
"/agents help",
|
||||
"/mcp help",
|
||||
"/skills help",
|
||||
] {
|
||||
completions.insert(candidate.to_string());
|
||||
@@ -5293,6 +5483,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
writeln!(out, " claw dump-manifests")?;
|
||||
writeln!(out, " claw bootstrap-plan")?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp")?;
|
||||
writeln!(out, " claw skills")?;
|
||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||
writeln!(out, " claw login")?;
|
||||
@@ -5364,6 +5555,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
||||
)?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp show my-server")?;
|
||||
writeln!(out, " claw /skills")?;
|
||||
writeln!(out, " claw login")?;
|
||||
writeln!(out, " claw init")?;
|
||||
@@ -5516,6 +5708,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn defaults_to_repl_when_no_args() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
assert_eq!(
|
||||
parse_args(&[]).expect("args should parse"),
|
||||
CliAction::Repl {
|
||||
@@ -5526,8 +5720,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_permission_mode_uses_project_config_when_env_is_unset() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
cwd.join(".claw").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("project config should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
|
||||
let resolved = with_current_dir(&cwd, super::default_permission_mode);
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_permission_mode {
|
||||
Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value),
|
||||
None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(resolved, PermissionMode::WorkspaceWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_permission_mode_overrides_project_config_default() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
cwd.join(".claw").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("project config should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only");
|
||||
|
||||
let resolved = with_current_dir(&cwd, super::default_permission_mode);
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_permission_mode {
|
||||
Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value),
|
||||
None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(resolved, PermissionMode::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prompt_subcommand() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
let args = vec![
|
||||
"prompt".to_string(),
|
||||
"hello".to_string(),
|
||||
@@ -5547,6 +5811,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_bare_prompt_and_json_output_flag() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
let args = vec![
|
||||
"--output-format=json".to_string(),
|
||||
"--model".to_string(),
|
||||
@@ -5568,6 +5834,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolves_model_aliases_in_args() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
let args = vec![
|
||||
"--model".to_string(),
|
||||
"opus".to_string(),
|
||||
@@ -5621,6 +5889,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_allowed_tools_flags_with_aliases_and_lists() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
let args = vec![
|
||||
"--allowedTools".to_string(),
|
||||
"read,glob".to_string(),
|
||||
@@ -5684,6 +5954,10 @@ mod tests {
|
||||
parse_args(&["agents".to_string()]).expect("agents should parse"),
|
||||
CliAction::Agents { args: None }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
|
||||
CliAction::Mcp { args: None }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["skills".to_string()]).expect("skills should parse"),
|
||||
CliAction::Skills { args: None }
|
||||
@@ -5699,6 +5973,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
assert_eq!(
|
||||
parse_args(&["help".to_string()]).expect("help should parse"),
|
||||
CliAction::Help
|
||||
@@ -5729,6 +6005,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
assert_eq!(
|
||||
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
||||
.expect("prompt shorthand should still work"),
|
||||
@@ -5743,11 +6021,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_agents_and_skills_slash_commands() {
|
||||
fn parses_direct_agents_mcp_and_skills_slash_commands() {
|
||||
assert_eq!(
|
||||
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
|
||||
CliAction::Agents { args: None }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
|
||||
.expect("/mcp show demo should parse"),
|
||||
CliAction::Mcp {
|
||||
args: Some("show demo".to_string())
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
|
||||
CliAction::Skills { args: None }
|
||||
@@ -5795,9 +6080,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn formats_unknown_slash_command_with_suggestions() {
|
||||
let report = format_unknown_slash_command_message("stats");
|
||||
assert!(report.contains("unknown slash command: /stats"));
|
||||
assert!(report.contains("Did you mean /status?"));
|
||||
let report = format_unknown_slash_command_message("statsu");
|
||||
assert!(report.contains("unknown slash command: /statsu"));
|
||||
assert!(report.contains("Did you mean"));
|
||||
assert!(report.contains("Use /help"));
|
||||
}
|
||||
|
||||
@@ -5965,6 +6250,7 @@ mod tests {
|
||||
assert!(help.contains("/cost"));
|
||||
assert!(help.contains("/resume <session-path>"));
|
||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||
assert!(help.contains("/mcp [list|show <server>|help]"));
|
||||
assert!(help.contains("/memory"));
|
||||
assert!(help.contains("/init"));
|
||||
assert!(help.contains("/diff"));
|
||||
@@ -5995,13 +6281,15 @@ mod tests {
|
||||
assert!(completions.contains(&"/session list".to_string()));
|
||||
assert!(completions.contains(&"/session switch session-current".to_string()));
|
||||
assert!(completions.contains(&"/resume session-old".to_string()));
|
||||
assert!(completions.contains(&"/mcp list".to_string()));
|
||||
assert!(completions.contains(&"/ultraplan ".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires ANTHROPIC_API_KEY"]
|
||||
fn startup_banner_mentions_workflow_completions() {
|
||||
let _guard = env_lock();
|
||||
// Inject dummy credentials so LiveCli can construct without real Anthropic key
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-banner-test");
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
|
||||
@@ -6020,6 +6308,7 @@ mod tests {
|
||||
assert!(banner.contains("workflow completions"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6028,13 +6317,12 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"help", "status", "sandbox", "compact", "clear", "cost", "config", "memory",
|
||||
"init", "diff", "version", "export", "agents", "skills",
|
||||
]
|
||||
);
|
||||
// Now with 135+ slash commands, verify minimum resume support
|
||||
assert!(names.len() >= 39, "expected at least 39 resume-supported commands, got {}", names.len());
|
||||
// Verify key resume commands still exist
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6104,6 +6392,7 @@ mod tests {
|
||||
assert!(help.contains("claw sandbox"));
|
||||
assert!(help.contains("claw init"));
|
||||
assert!(help.contains("claw agents"));
|
||||
assert!(help.contains("claw mcp"));
|
||||
assert!(help.contains("claw skills"));
|
||||
assert!(help.contains("claw /skills"));
|
||||
}
|
||||
@@ -7116,6 +7405,9 @@ UU conflicted.rs",
|
||||
#[test]
|
||||
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
|
||||
let config_home = temp_dir();
|
||||
// Inject a dummy API key so runtime construction succeeds without real credentials.
|
||||
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle");
|
||||
let workspace = temp_dir();
|
||||
let source_root = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
@@ -7164,6 +7456,7 @@ UU conflicted.rs",
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
|
||||
.expect("claw should launch");
|
||||
let unknown_output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.arg("/stats")
|
||||
.arg("/zstats")
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
@@ -97,7 +97,7 @@ fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
|
||||
String::from_utf8_lossy(&unknown_output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(unknown_output.stderr).expect("stderr should be utf8");
|
||||
assert!(stderr.contains("unknown slash command outside the REPL: /stats"));
|
||||
assert!(stderr.contains("unknown slash command outside the REPL: /zstats"));
|
||||
assert!(stderr.contains("Did you mean"));
|
||||
assert!(stderr.contains("/status"));
|
||||
|
||||
|
||||
876
rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs
Normal file
876
rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs
Normal file
@@ -0,0 +1,876 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios() {
|
||||
let manifest_entries = load_scenario_manifest();
|
||||
let manifest = manifest_entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| (entry.name.clone(), entry))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let cases = [
|
||||
ScenarioCase {
|
||||
name: "streaming_text",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_streaming_text,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "read_file_roundtrip",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("read_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_read_fixture,
|
||||
assert: assert_read_file_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "grep_chunk_assembly",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("grep_search"),
|
||||
stdin: None,
|
||||
prepare: prepare_grep_fixture,
|
||||
assert: assert_grep_chunk_assembly,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "write_file_allowed",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("write_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_write_file_allowed,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "write_file_denied",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("write_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_write_file_denied,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "multi_tool_turn_roundtrip",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("read_file,grep_search"),
|
||||
stdin: None,
|
||||
prepare: prepare_multi_tool_fixture,
|
||||
assert: assert_multi_tool_turn_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_stdout_roundtrip",
|
||||
permission_mode: "danger-full-access",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_stdout_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_permission_prompt_approved",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: Some("y\n"),
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_permission_prompt_approved,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_permission_prompt_denied",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: Some("n\n"),
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_permission_prompt_denied,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "plugin_tool_roundtrip",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_plugin_fixture,
|
||||
assert: assert_plugin_tool_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "auto_compact_triggered",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_auto_compact_triggered,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "token_cost_reporting",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_token_cost_reporting,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
];
|
||||
|
||||
let case_names = cases.iter().map(|case| case.name).collect::<Vec<_>>();
|
||||
let manifest_names = manifest_entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
case_names, manifest_names,
|
||||
"manifest and harness cases must stay aligned"
|
||||
);
|
||||
|
||||
let mut scenario_reports = Vec::new();
|
||||
|
||||
for case in cases {
|
||||
let workspace = HarnessWorkspace::new(unique_temp_dir(case.name));
|
||||
workspace.create().expect("workspace should exist");
|
||||
(case.prepare)(&workspace);
|
||||
|
||||
let run = run_case(case, &workspace, &base_url);
|
||||
(case.assert)(&workspace, &run);
|
||||
|
||||
let manifest_entry = manifest
|
||||
.get(case.name)
|
||||
.unwrap_or_else(|| panic!("missing manifest entry for {}", case.name));
|
||||
scenario_reports.push(build_scenario_report(
|
||||
case.name,
|
||||
manifest_entry,
|
||||
&run.response,
|
||||
));
|
||||
|
||||
fs::remove_dir_all(&workspace.root).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
assert_eq!(
|
||||
captured.len(),
|
||||
21,
|
||||
"twelve scenarios should produce twenty-one requests"
|
||||
);
|
||||
assert!(captured
|
||||
.iter()
|
||||
.all(|request| request.path == "/v1/messages"));
|
||||
assert!(captured.iter().all(|request| request.stream));
|
||||
|
||||
let scenarios = captured
|
||||
.iter()
|
||||
.map(|request| request.scenario.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
scenarios,
|
||||
vec![
|
||||
"streaming_text",
|
||||
"read_file_roundtrip",
|
||||
"read_file_roundtrip",
|
||||
"grep_chunk_assembly",
|
||||
"grep_chunk_assembly",
|
||||
"write_file_allowed",
|
||||
"write_file_allowed",
|
||||
"write_file_denied",
|
||||
"write_file_denied",
|
||||
"multi_tool_turn_roundtrip",
|
||||
"multi_tool_turn_roundtrip",
|
||||
"bash_stdout_roundtrip",
|
||||
"bash_stdout_roundtrip",
|
||||
"bash_permission_prompt_approved",
|
||||
"bash_permission_prompt_approved",
|
||||
"bash_permission_prompt_denied",
|
||||
"bash_permission_prompt_denied",
|
||||
"plugin_tool_roundtrip",
|
||||
"plugin_tool_roundtrip",
|
||||
"auto_compact_triggered",
|
||||
"token_cost_reporting",
|
||||
]
|
||||
);
|
||||
|
||||
let mut request_counts = BTreeMap::new();
|
||||
for request in &captured {
|
||||
*request_counts
|
||||
.entry(request.scenario.as_str())
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
for report in &mut scenario_reports {
|
||||
report.request_count = *request_counts
|
||||
.get(report.name.as_str())
|
||||
.unwrap_or_else(|| panic!("missing request count for {}", report.name));
|
||||
}
|
||||
|
||||
maybe_write_report(&scenario_reports);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScenarioCase {
|
||||
name: &'static str,
|
||||
permission_mode: &'static str,
|
||||
allowed_tools: Option<&'static str>,
|
||||
stdin: Option<&'static str>,
|
||||
prepare: fn(&HarnessWorkspace),
|
||||
assert: fn(&HarnessWorkspace, &ScenarioRun),
|
||||
extra_env: Option<(&'static str, &'static str)>,
|
||||
resume_session: Option<&'static str>,
|
||||
}
|
||||
|
||||
struct HarnessWorkspace {
|
||||
root: PathBuf,
|
||||
config_home: PathBuf,
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl HarnessWorkspace {
|
||||
fn new(root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_home: root.join("config-home"),
|
||||
home: root.join("home"),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
fn create(&self) -> std::io::Result<()> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
fs::create_dir_all(&self.config_home)?;
|
||||
fs::create_dir_all(&self.home)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct ScenarioRun {
|
||||
response: Value,
|
||||
stdout: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScenarioManifestEntry {
|
||||
name: String,
|
||||
category: String,
|
||||
description: String,
|
||||
parity_refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScenarioReport {
|
||||
name: String,
|
||||
category: String,
|
||||
description: String,
|
||||
parity_refs: Vec<String>,
|
||||
iterations: u64,
|
||||
request_count: usize,
|
||||
tool_uses: Vec<String>,
|
||||
tool_error_count: usize,
|
||||
final_message: String,
|
||||
}
|
||||
|
||||
fn run_case(case: ScenarioCase, workspace: &HarnessWorkspace, base_url: &str) -> ScenarioRun {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command
|
||||
.current_dir(&workspace.root)
|
||||
.env_clear()
|
||||
.env("ANTHROPIC_API_KEY", "test-parity-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", &workspace.config_home)
|
||||
.env("HOME", &workspace.home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
case.permission_mode,
|
||||
"--output-format=json",
|
||||
]);
|
||||
|
||||
if let Some(allowed_tools) = case.allowed_tools {
|
||||
command.args(["--allowedTools", allowed_tools]);
|
||||
}
|
||||
if let Some((key, value)) = case.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
if let Some(session_id) = case.resume_session {
|
||||
command.args(["--resume", session_id]);
|
||||
}
|
||||
|
||||
let prompt = format!("{SCENARIO_PREFIX}{}", case.name);
|
||||
command.arg(prompt);
|
||||
|
||||
let output = if let Some(stdin) = case.stdin {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("claw should launch");
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin should be piped")
|
||||
.write_all(stdin.as_bytes())
|
||||
.expect("stdin should write");
|
||||
child.wait_with_output().expect("claw should finish")
|
||||
} else {
|
||||
command.output().expect("claw should launch")
|
||||
};
|
||||
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
ScenarioRun {
|
||||
response: parse_json_output(&stdout),
|
||||
stdout,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn prepare_auto_compact_fixture(workspace: &HarnessWorkspace) {
|
||||
let sessions_dir = workspace.root.join(".claw").join("sessions");
|
||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
||||
|
||||
// Write a pre-seeded session with 6 messages so auto-compact can remove them
|
||||
let session_id = "parity-auto-compact-seed";
|
||||
let session_jsonl = r#"{"type":"session_meta","version":3,"session_id":"parity-auto-compact-seed","created_at_ms":1743724800000,"updated_at_ms":1743724800000}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step one of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step one"}]}}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step two of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step two"}]}}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step three of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step three"}]}}
|
||||
"#;
|
||||
fs::write(
|
||||
sessions_dir.join(format!("{session_id}.jsonl")),
|
||||
session_jsonl,
|
||||
)
|
||||
.expect("pre-seeded session should write");
|
||||
}
|
||||
|
||||
fn prepare_noop(_: &HarnessWorkspace) {}
|
||||
|
||||
fn prepare_read_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(workspace.root.join("fixture.txt"), "alpha parity line\n")
|
||||
.expect("fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_grep_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(
|
||||
workspace.root.join("fixture.txt"),
|
||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
||||
)
|
||||
.expect("grep fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_multi_tool_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(
|
||||
workspace.root.join("fixture.txt"),
|
||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
||||
)
|
||||
.expect("multi tool fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
||||
let plugin_root = workspace
|
||||
.root
|
||||
.join("external-plugins")
|
||||
.join("parity-plugin");
|
||||
let tool_dir = plugin_root.join("tools");
|
||||
let manifest_dir = plugin_root.join(".claude-plugin");
|
||||
fs::create_dir_all(&tool_dir).expect("plugin tools dir");
|
||||
fs::create_dir_all(&manifest_dir).expect("plugin manifest dir");
|
||||
|
||||
let script_path = tool_dir.join("echo-json.sh");
|
||||
fs::write(
|
||||
&script_path,
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
)
|
||||
.expect("plugin script should write");
|
||||
let mut permissions = fs::metadata(&script_path)
|
||||
.expect("plugin script metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
||||
|
||||
fs::write(
|
||||
manifest_dir.join("plugin.json"),
|
||||
r#"{
|
||||
"name": "parity-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "mock parity plugin",
|
||||
"tools": [
|
||||
{
|
||||
"name": "plugin_echo",
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" }
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"command": "./tools/echo-json.sh",
|
||||
"requiredPermission": "workspace-write"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("plugin manifest should write");
|
||||
|
||||
fs::write(
|
||||
workspace.config_home.join("settings.json"),
|
||||
json!({
|
||||
"enabledPlugins": {
|
||||
"parity-plugin@external": true
|
||||
},
|
||||
"plugins": {
|
||||
"externalDirectories": [plugin_root.parent().expect("plugin parent").display().to_string()]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("plugin settings should write");
|
||||
}
|
||||
|
||||
fn assert_streaming_text(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(
|
||||
run.response["message"],
|
||||
Value::String("Mock streaming says hello from the parity harness.".to_string())
|
||||
);
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
||||
assert_eq!(run.response["tool_results"], Value::Array(Vec::new()));
|
||||
}
|
||||
|
||||
fn assert_read_file_roundtrip(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("read_file".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["input"],
|
||||
Value::String(r#"{"path":"fixture.txt"}"#.to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha parity line"));
|
||||
let output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(output.contains(&workspace.root.join("fixture.txt").display().to_string()));
|
||||
assert!(output.contains("alpha parity line"));
|
||||
}
|
||||
|
||||
fn assert_grep_chunk_assembly(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("grep_search".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["input"],
|
||||
Value::String(
|
||||
r#"{"pattern":"parity","path":"fixture.txt","output_mode":"count"}"#.to_string()
|
||||
)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("2 occurrences"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_write_file_allowed(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("write_file".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("generated/output.txt"));
|
||||
let generated = workspace.root.join("generated").join("output.txt");
|
||||
let contents = fs::read_to_string(&generated).expect("generated file should exist");
|
||||
assert_eq!(contents, "created by mock service\n");
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_write_file_denied(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("write_file".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(tool_output.contains("requires workspace-write permission"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("denied as expected"));
|
||||
assert!(!workspace.root.join("generated").join("denied.txt").exists());
|
||||
}
|
||||
|
||||
fn assert_multi_tool_turn_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
let tool_uses = run.response["tool_uses"]
|
||||
.as_array()
|
||||
.expect("tool uses array");
|
||||
assert_eq!(
|
||||
tool_uses.len(),
|
||||
2,
|
||||
"expected two tool uses in a single turn"
|
||||
);
|
||||
assert_eq!(tool_uses[0]["name"], Value::String("read_file".to_string()));
|
||||
assert_eq!(
|
||||
tool_uses[1]["name"],
|
||||
Value::String("grep_search".to_string())
|
||||
);
|
||||
let tool_results = run.response["tool_results"]
|
||||
.as_array()
|
||||
.expect("tool results array");
|
||||
assert_eq!(
|
||||
tool_results.len(),
|
||||
2,
|
||||
"expected two tool results in a single turn"
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha parity line"));
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("2 occurrences"));
|
||||
}
|
||||
|
||||
fn assert_bash_stdout_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("bash".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
||||
assert_eq!(
|
||||
parsed["stdout"],
|
||||
Value::String("alpha from bash".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha from bash"));
|
||||
}
|
||||
|
||||
fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert!(run.stdout.contains("Permission approval required"));
|
||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
||||
assert_eq!(
|
||||
parsed["stdout"],
|
||||
Value::String("approved via prompt".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("approved and executed"));
|
||||
}
|
||||
|
||||
fn assert_bash_permission_prompt_denied(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert!(run.stdout.contains("Permission approval required"));
|
||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(tool_output.contains("denied by user approval prompt"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("denied as expected"));
|
||||
}
|
||||
|
||||
fn assert_plugin_tool_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("plugin_echo".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("plugin output json");
|
||||
assert_eq!(
|
||||
parsed["plugin"],
|
||||
Value::String("parity-plugin@external".to_string())
|
||||
);
|
||||
assert_eq!(parsed["tool"], Value::String("plugin_echo".to_string()));
|
||||
assert_eq!(
|
||||
parsed["input"]["message"],
|
||||
Value::String("hello from plugin parity".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("hello from plugin parity"));
|
||||
}
|
||||
|
||||
fn assert_auto_compact_triggered(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
// Validates that the auto_compaction field is present in JSON output (format parity).
|
||||
// Trigger behavior is covered by conversation::tests::auto_compacts_when_cumulative_input_threshold_is_crossed.
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
||||
assert!(
|
||||
run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("auto compact parity complete."),
|
||||
"expected auto compact message in response"
|
||||
);
|
||||
// auto_compaction key must be present in JSON (may be null for below-threshold sessions)
|
||||
assert!(
|
||||
run.response.as_object().expect("response object").contains_key("auto_compaction"),
|
||||
"auto_compaction key must be present in JSON output"
|
||||
);
|
||||
// Verify input_tokens field reflects the large mock token counts
|
||||
let input_tokens = run.response["usage"]["input_tokens"]
|
||||
.as_u64()
|
||||
.expect("input_tokens should be present");
|
||||
assert!(
|
||||
input_tokens >= 50_000,
|
||||
"input_tokens should reflect mock service value (got {input_tokens})"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert!(
|
||||
run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("token cost reporting parity complete."),
|
||||
);
|
||||
let usage = &run.response["usage"];
|
||||
assert!(
|
||||
usage["input_tokens"].as_u64().unwrap_or(0) > 0,
|
||||
"input_tokens should be non-zero"
|
||||
);
|
||||
assert!(
|
||||
usage["output_tokens"].as_u64().unwrap_or(0) > 0,
|
||||
"output_tokens should be non-zero"
|
||||
);
|
||||
assert!(
|
||||
run.response["estimated_cost"]
|
||||
.as_str()
|
||||
.map(|cost| cost.starts_with('$'))
|
||||
.unwrap_or(false),
|
||||
"estimated_cost should be a dollar-prefixed string"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_json_output(stdout: &str) -> Value {
|
||||
if let Some(index) = stdout.rfind("{\"auto_compaction\"") {
|
||||
return serde_json::from_str(&stdout[index..]).unwrap_or_else(|error| {
|
||||
panic!("failed to parse JSON response from stdout: {error}\n{stdout}")
|
||||
});
|
||||
}
|
||||
|
||||
stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('{') && trimmed.ends_with('}') {
|
||||
serde_json::from_str(trimmed).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no JSON response line found in stdout:\n{stdout}"))
|
||||
}
|
||||
|
||||
fn build_scenario_report(
|
||||
name: &str,
|
||||
manifest_entry: &ScenarioManifestEntry,
|
||||
response: &Value,
|
||||
) -> ScenarioReport {
|
||||
ScenarioReport {
|
||||
name: name.to_string(),
|
||||
category: manifest_entry.category.clone(),
|
||||
description: manifest_entry.description.clone(),
|
||||
parity_refs: manifest_entry.parity_refs.clone(),
|
||||
iterations: response["iterations"]
|
||||
.as_u64()
|
||||
.expect("iterations should exist"),
|
||||
request_count: 0,
|
||||
tool_uses: response["tool_uses"]
|
||||
.as_array()
|
||||
.expect("tool uses array")
|
||||
.iter()
|
||||
.filter_map(|value| value["name"].as_str().map(ToOwned::to_owned))
|
||||
.collect(),
|
||||
tool_error_count: response["tool_results"]
|
||||
.as_array()
|
||||
.expect("tool results array")
|
||||
.iter()
|
||||
.filter(|value| value["is_error"].as_bool().unwrap_or(false))
|
||||
.count(),
|
||||
final_message: response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_write_report(reports: &[ScenarioReport]) {
|
||||
let Some(path) = std::env::var_os("MOCK_PARITY_REPORT_PATH") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"scenario_count": reports.len(),
|
||||
"request_count": reports.iter().map(|report| report.request_count).sum::<usize>(),
|
||||
"scenarios": reports.iter().map(scenario_report_json).collect::<Vec<_>>(),
|
||||
});
|
||||
fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(&payload).expect("report json should serialize"),
|
||||
)
|
||||
.expect("report should write");
|
||||
}
|
||||
|
||||
fn load_scenario_manifest() -> Vec<ScenarioManifestEntry> {
|
||||
let manifest_path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mock_parity_scenarios.json");
|
||||
let manifest = fs::read_to_string(&manifest_path).expect("scenario manifest should exist");
|
||||
serde_json::from_str::<Vec<Value>>(&manifest)
|
||||
.expect("scenario manifest should parse")
|
||||
.into_iter()
|
||||
.map(|entry| ScenarioManifestEntry {
|
||||
name: entry["name"]
|
||||
.as_str()
|
||||
.expect("scenario name should be a string")
|
||||
.to_string(),
|
||||
category: entry["category"]
|
||||
.as_str()
|
||||
.expect("scenario category should be a string")
|
||||
.to_string(),
|
||||
description: entry["description"]
|
||||
.as_str()
|
||||
.expect("scenario description should be a string")
|
||||
.to_string(),
|
||||
parity_refs: entry["parity_refs"]
|
||||
.as_array()
|
||||
.expect("parity refs should be an array")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.expect("parity ref should be a string")
|
||||
.to_string()
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scenario_report_json(report: &ScenarioReport) -> Value {
|
||||
json!({
|
||||
"name": report.name,
|
||||
"category": report.category,
|
||||
"description": report.description,
|
||||
"parity_refs": report.parity_refs,
|
||||
"iterations": report.iterations,
|
||||
"request_count": report.request_count,
|
||||
"tool_uses": report.tool_uses,
|
||||
"tool_error_count": report.tool_error_count,
|
||||
"final_message": report.final_message,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-mock-parity-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use runtime::ContentBlock;
|
||||
use runtime::Session;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -51,7 +52,12 @@ fn resumed_binary_accepts_slash_commands_with_arguments() {
|
||||
assert!(stdout.contains("Export"));
|
||||
assert!(stdout.contains("wrote transcript"));
|
||||
assert!(stdout.contains(export_path.to_str().expect("utf8 path")));
|
||||
assert!(stdout.contains("Cleared resumed session file"));
|
||||
assert!(stdout.contains("Session cleared"));
|
||||
assert!(stdout.contains("Mode resumed session reset"));
|
||||
assert!(stdout.contains("Previous session"));
|
||||
assert!(stdout.contains("Resume previous claw --resume"));
|
||||
assert!(stdout.contains("Backup "));
|
||||
assert!(stdout.contains("Session file "));
|
||||
|
||||
let export = fs::read_to_string(&export_path).expect("export file should exist");
|
||||
assert!(export.contains("# Conversation Export"));
|
||||
@@ -59,6 +65,18 @@ fn resumed_binary_accepts_slash_commands_with_arguments() {
|
||||
|
||||
let restored = Session::load_from_path(&session_path).expect("cleared session should load");
|
||||
assert!(restored.messages.is_empty());
|
||||
|
||||
let backup_path = stdout
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix(" Backup "))
|
||||
.map(PathBuf::from)
|
||||
.expect("clear output should include backup path");
|
||||
let backup = Session::load_from_path(&backup_path).expect("backup session should load");
|
||||
assert_eq!(backup.messages.len(), 1);
|
||||
assert!(matches!(
|
||||
backup.messages[0].blocks.first(),
|
||||
Some(ContentBlock::Text { text }) if text == "ship the slash command harness"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user