generated from nhcarrigan/template
feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: #221 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #221.
This commit is contained in:
+561
-25
@@ -17,7 +17,7 @@ use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
|
||||
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::cell::RefCell;
|
||||
@@ -291,6 +291,42 @@ impl WslBridge {
|
||||
cmd.arg("--worktree");
|
||||
}
|
||||
|
||||
// Pass combined settings via --settings flag if any settings are specified
|
||||
{
|
||||
let has_memory_dir = options
|
||||
.auto_memory_directory
|
||||
.as_deref()
|
||||
.map(|d| !d.is_empty())
|
||||
.unwrap_or(false);
|
||||
let has_overrides = options
|
||||
.model_overrides
|
||||
.as_ref()
|
||||
.map(|m| !m.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_memory_dir || has_overrides {
|
||||
let mut settings = serde_json::Map::new();
|
||||
if let Some(ref dir) = options.auto_memory_directory {
|
||||
if !dir.is_empty() {
|
||||
settings.insert(
|
||||
"autoMemoryDirectory".to_string(),
|
||||
serde_json::Value::String(dir.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref overrides) = options.model_overrides {
|
||||
if !overrides.is_empty() {
|
||||
if let Ok(val) = serde_json::to_value(overrides) {
|
||||
settings.insert("modelOverrides".to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
||||
cmd.args(["--settings", &settings_json]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd.current_dir(working_dir);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
@@ -310,6 +346,21 @@ impl WslBridge {
|
||||
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
|
||||
}
|
||||
|
||||
// Disable cron scheduling if requested
|
||||
if options.disable_cron {
|
||||
cmd.env("CLAUDE_CODE_DISABLE_CRON", "1");
|
||||
}
|
||||
|
||||
// Disable built-in git instructions if requested
|
||||
if !options.include_git_instructions {
|
||||
cmd.env("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS", "1");
|
||||
}
|
||||
|
||||
// Opt out of claude.ai MCP servers if requested
|
||||
if !options.enable_claudeai_mcp_servers {
|
||||
cmd.env("ENABLE_CLAUDEAI_MCP_SERVERS", "false");
|
||||
}
|
||||
|
||||
cmd
|
||||
} else {
|
||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||
@@ -362,6 +413,21 @@ impl WslBridge {
|
||||
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
|
||||
}
|
||||
|
||||
// Disable cron scheduling if requested
|
||||
if options.disable_cron {
|
||||
claude_cmd.push_str("CLAUDE_CODE_DISABLE_CRON=1 ");
|
||||
}
|
||||
|
||||
// Disable built-in git instructions if requested
|
||||
if !options.include_git_instructions {
|
||||
claude_cmd.push_str("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 ");
|
||||
}
|
||||
|
||||
// Opt out of claude.ai MCP servers if requested
|
||||
if !options.enable_claudeai_mcp_servers {
|
||||
claude_cmd.push_str("ENABLE_CLAUDEAI_MCP_SERVERS=false ");
|
||||
}
|
||||
|
||||
claude_cmd.push_str(
|
||||
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||
);
|
||||
@@ -404,6 +470,43 @@ impl WslBridge {
|
||||
claude_cmd.push_str(" --worktree");
|
||||
}
|
||||
|
||||
// Pass combined settings via --settings flag if any settings are specified
|
||||
{
|
||||
let has_memory_dir = options
|
||||
.auto_memory_directory
|
||||
.as_deref()
|
||||
.map(|d| !d.is_empty())
|
||||
.unwrap_or(false);
|
||||
let has_overrides = options
|
||||
.model_overrides
|
||||
.as_ref()
|
||||
.map(|m| !m.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_memory_dir || has_overrides {
|
||||
let mut settings = serde_json::Map::new();
|
||||
if let Some(ref dir) = options.auto_memory_directory {
|
||||
if !dir.is_empty() {
|
||||
settings.insert(
|
||||
"autoMemoryDirectory".to_string(),
|
||||
serde_json::Value::String(dir.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref overrides) = options.model_overrides {
|
||||
if !overrides.is_empty() {
|
||||
if let Ok(val) = serde_json::to_value(overrides) {
|
||||
settings.insert("modelOverrides".to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
||||
let escaped = settings_json.replace('\'', "'\\''");
|
||||
claude_cmd.push_str(&format!(" --settings '{}'", escaped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use bash -lc to load login profile (ensures PATH includes claude)
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
@@ -903,13 +1006,14 @@ fn handle_stderr(
|
||||
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
|
||||
agent_data.agent_id, agent_data.parent_tool_use_id);
|
||||
|
||||
// Emit an agent-update event with the agent_id
|
||||
// Emit an agent-update event with the agent_id and agent_type
|
||||
let _ = app.emit(
|
||||
"claude:agent-update",
|
||||
serde_json::json!({
|
||||
"conversationId": conversation_id.clone(),
|
||||
"toolUseId": agent_data.parent_tool_use_id,
|
||||
"agentId": agent_data.agent_id,
|
||||
"agentType": agent_data.agent_type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -945,9 +1049,10 @@ fn handle_stderr(
|
||||
}
|
||||
|
||||
// Hook events are informational — emit with distinct types instead of error
|
||||
let line_type = if line.contains("[WorktreeCreate Hook]")
|
||||
|| line.contains("[WorktreeRemove Hook]")
|
||||
{
|
||||
let is_worktree_create = line.contains("[WorktreeCreate Hook]");
|
||||
let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
|
||||
|
||||
let line_type = if is_worktree_create || is_worktree_remove {
|
||||
"worktree"
|
||||
} else if line.contains("[ConfigChange Hook]") {
|
||||
"config-change"
|
||||
@@ -955,17 +1060,56 @@ fn handle_stderr(
|
||||
"error"
|
||||
};
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: line_type.to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
// For worktree hooks, parse structured data and emit a dedicated event
|
||||
if is_worktree_create || is_worktree_remove {
|
||||
let worktree_info = parse_worktree_hook(&line);
|
||||
let event_type = if is_worktree_create { "create" } else { "remove" };
|
||||
let friendly_content = if let Some(ref info) = worktree_info {
|
||||
if is_worktree_create {
|
||||
format!(
|
||||
"Worktree created: {} (branch: {}) at {}",
|
||||
info.name, info.branch, info.path
|
||||
)
|
||||
} else {
|
||||
format!("Worktree removed: {} (branch: {})", info.name, info.branch)
|
||||
}
|
||||
} else {
|
||||
line.clone()
|
||||
};
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:worktree",
|
||||
WorktreeEvent {
|
||||
conversation_id: conversation_id.clone(),
|
||||
event_type: event_type.to_string(),
|
||||
worktree: worktree_info,
|
||||
},
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "worktree".to_string(),
|
||||
content: friendly_content,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: line_type.to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
@@ -976,11 +1120,35 @@ fn handle_stderr(
|
||||
#[derive(Debug)]
|
||||
struct SubagentStartData {
|
||||
agent_id: String,
|
||||
agent_type: Option<String>,
|
||||
parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_worktree_hook(line: &str) -> Option<WorktreeInfo> {
|
||||
// Parse: [WorktreeCreate/Remove Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc,
|
||||
// branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=xxx
|
||||
|
||||
let extract = |key: &str| -> Option<String> {
|
||||
let after_key = line.split(&format!("{}=", key)).nth(1)?;
|
||||
let value = after_key.split(',').next()?.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
};
|
||||
|
||||
let name = extract("name")?;
|
||||
let path = extract("path")?;
|
||||
let branch = extract("branch")?;
|
||||
let original_repo_directory = extract("original_repo_directory")?;
|
||||
|
||||
Some(WorktreeInfo {
|
||||
name,
|
||||
path,
|
||||
branch,
|
||||
original_repo_directory,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
// Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ...
|
||||
// Parse: [SubagentStart Hook] agent_id=agent-xxx, agent_type=general-purpose, parent_tool_use_id=Some("toolu_xxx"), ...
|
||||
|
||||
// Extract agent_id
|
||||
let agent_id = line
|
||||
@@ -991,6 +1159,15 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Extract agent_type if present (added in CLI v2.1.69)
|
||||
let agent_type = line
|
||||
.split("agent_type=")
|
||||
.nth(1)
|
||||
.and_then(|s| {
|
||||
let value = s.split(',').next()?.trim();
|
||||
if value.is_empty() { None } else { Some(value.to_string()) }
|
||||
});
|
||||
|
||||
// Extract parent_tool_use_id if present
|
||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||
line.split("parent_tool_use_id=Some(\"")
|
||||
@@ -1004,6 +1181,7 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
|
||||
Some(SubagentStartData {
|
||||
agent_id,
|
||||
agent_type,
|
||||
parent_tool_use_id,
|
||||
})
|
||||
}
|
||||
@@ -1128,6 +1306,12 @@ fn process_json_line(
|
||||
}
|
||||
|
||||
ClaudeMessage::Assistant { message, parent_tool_use_id } => {
|
||||
// Claude is actively responding — reset the watchdog timer so a long multi-step
|
||||
// response (e.g. spawning subagents, chained tool calls) is not mistaken for a
|
||||
// stuck process. The watchdog should only fire if Claude goes completely silent,
|
||||
// not merely because the total turn duration exceeds the threshold.
|
||||
*pending_since.lock() = Some(Instant::now());
|
||||
|
||||
let mut state = CharacterState::Typing;
|
||||
let mut tool_name = None;
|
||||
|
||||
@@ -1256,27 +1440,36 @@ fn process_json_line(
|
||||
}
|
||||
}
|
||||
|
||||
// Emit agent-start event for Task tool invocations
|
||||
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
|
||||
if name == "Task" || name.starts_with("Task(") {
|
||||
// Emit agent-start event for Task/Agent tool invocations
|
||||
// Support "Task"/"Task(agent_type)" (CLI v2.1.33+) and
|
||||
// "Agent"/"Agent(agent_type)" (CLI v2.1.69+ rename)
|
||||
if name == "Task" || name.starts_with("Task(")
|
||||
|| name == "Agent" || name.starts_with("Agent(")
|
||||
{
|
||||
let description = input
|
||||
.get("description")
|
||||
.or_else(|| input.get("prompt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Subagent")
|
||||
.to_string();
|
||||
let subagent_type = input
|
||||
.get("subagent_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.unwrap_or("general-purpose")
|
||||
.to_string();
|
||||
let model = input
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
|
||||
tracing::debug!(
|
||||
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
|
||||
id, description, subagent_type, parent_tool_use_id
|
||||
"Emitting agent-start: id={}, desc={}, type={}, model={:?}, parent={:?}",
|
||||
id, description, subagent_type, model, parent_tool_use_id
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
@@ -1286,6 +1479,7 @@ fn process_json_line(
|
||||
agent_id: None, // Will be updated when SubagentStart hook is received
|
||||
description,
|
||||
subagent_type,
|
||||
model,
|
||||
started_at: now,
|
||||
conversation_id: conversation_id.clone(),
|
||||
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||
@@ -1650,7 +1844,10 @@ fn process_json_line(
|
||||
|
||||
// Helper function to check if a tool is a system tool that should never require permission
|
||||
let is_system_tool = |tool_name: &str| -> bool {
|
||||
matches!(tool_name, "ExitPlanMode" | "EnterPlanMode")
|
||||
matches!(
|
||||
tool_name,
|
||||
"ExitPlanMode" | "EnterPlanMode" | "EnterWorktree" | "ExitWorktree"
|
||||
)
|
||||
};
|
||||
|
||||
for denial in denials {
|
||||
@@ -1924,7 +2121,9 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
CharacterState::Coding
|
||||
} else if tool_name.starts_with("mcp__") {
|
||||
CharacterState::Mcp
|
||||
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
|
||||
} else if tool_name == "Task" || tool_name.starts_with("Task(")
|
||||
|| tool_name == "Agent" || tool_name.starts_with("Agent(")
|
||||
{
|
||||
CharacterState::Thinking
|
||||
} else {
|
||||
CharacterState::Typing
|
||||
@@ -2025,6 +2224,42 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
"Running command...".to_string()
|
||||
}
|
||||
}
|
||||
"EnterWorktree" => {
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
format!("Entering worktree: {}", path)
|
||||
} else {
|
||||
"Entering worktree session...".to_string()
|
||||
}
|
||||
}
|
||||
"ExitWorktree" => "Exiting worktree session...".to_string(),
|
||||
"CronCreate" => {
|
||||
if let Some(prompt) = input.get("prompt").and_then(|v| v.as_str()) {
|
||||
format!("Scheduling: {}", prompt)
|
||||
} else {
|
||||
"Scheduling recurring task...".to_string()
|
||||
}
|
||||
}
|
||||
"CronDelete" => {
|
||||
if let Some(id) = input.get("id").and_then(|v| v.as_str()) {
|
||||
format!("Removing scheduled task: {}", id)
|
||||
} else {
|
||||
"Removing scheduled task...".to_string()
|
||||
}
|
||||
}
|
||||
"CronList" => "Listing scheduled tasks...".to_string(),
|
||||
name if name == "Agent" || name.starts_with("Agent(") => {
|
||||
let task = input
|
||||
.get("description")
|
||||
.or_else(|| input.get("prompt"))
|
||||
.and_then(|v| v.as_str());
|
||||
let agent_type = input.get("subagent_type").and_then(|v| v.as_str());
|
||||
match (task, agent_type) {
|
||||
(Some(t), Some(a)) => format!("Launching {} agent: {}", a, t),
|
||||
(Some(t), None) => format!("Launching agent: {}", t),
|
||||
(None, Some(a)) => format!("Launching {} agent...", a),
|
||||
(None, None) => "Launching agent...".to_string(),
|
||||
}
|
||||
}
|
||||
_ => format!("Using tool: {}", name),
|
||||
}
|
||||
}
|
||||
@@ -2118,6 +2353,24 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_state_agent() {
|
||||
// CLI v2.1.69+ renamed Task to Agent
|
||||
assert!(matches!(get_tool_state("Agent"), CharacterState::Thinking));
|
||||
assert!(matches!(
|
||||
get_tool_state("Agent(Explore)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Agent(Plan)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Agent(general-purpose)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_state_unknown() {
|
||||
assert!(matches!(
|
||||
@@ -2191,6 +2444,97 @@ mod tests {
|
||||
assert_eq!(desc, "Using tool: CustomTool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_enter_worktree() {
|
||||
let input = serde_json::json!({"path": "/home/naomi/worktrees/feature-branch"});
|
||||
let desc = format_tool_description("EnterWorktree", &input);
|
||||
assert_eq!(desc, "Entering worktree: /home/naomi/worktrees/feature-branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_enter_worktree_no_path() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("EnterWorktree", &input);
|
||||
assert_eq!(desc, "Entering worktree session...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_exit_worktree() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("ExitWorktree", &input);
|
||||
assert_eq!(desc, "Exiting worktree session...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_cron_create() {
|
||||
let input = serde_json::json!({"prompt": "run tests", "schedule": "*/5 * * * *"});
|
||||
let desc = format_tool_description("CronCreate", &input);
|
||||
assert_eq!(desc, "Scheduling: run tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_cron_create_no_prompt() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("CronCreate", &input);
|
||||
assert_eq!(desc, "Scheduling recurring task...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_cron_delete() {
|
||||
let input = serde_json::json!({"id": "cron-abc123"});
|
||||
let desc = format_tool_description("CronDelete", &input);
|
||||
assert_eq!(desc, "Removing scheduled task: cron-abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_cron_delete_no_id() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("CronDelete", &input);
|
||||
assert_eq!(desc, "Removing scheduled task...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_cron_list() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("CronList", &input);
|
||||
assert_eq!(desc, "Listing scheduled tasks...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_agent_with_type_and_description() {
|
||||
let input = serde_json::json!({"subagent_type": "general-purpose", "description": "Fetch user info"});
|
||||
let desc = format_tool_description("Agent", &input);
|
||||
assert_eq!(desc, "Launching general-purpose agent: Fetch user info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_agent_with_prompt() {
|
||||
let input = serde_json::json!({"subagent_type": "Explore", "prompt": "Look at the repo"});
|
||||
let desc = format_tool_description("Agent", &input);
|
||||
assert_eq!(desc, "Launching Explore agent: Look at the repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_agent_no_description() {
|
||||
let input = serde_json::json!({"subagent_type": "Plan"});
|
||||
let desc = format_tool_description("Agent", &input);
|
||||
assert_eq!(desc, "Launching Plan agent...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_agent_no_fields() {
|
||||
let input = serde_json::json!({});
|
||||
let desc = format_tool_description("Agent", &input);
|
||||
assert_eq!(desc, "Launching agent...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_agent_with_parenthesized_type() {
|
||||
let input = serde_json::json!({"description": "Check files"});
|
||||
let desc = format_tool_description("Agent(Explore)", &input);
|
||||
assert_eq!(desc, "Launching agent: Check files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_read() {
|
||||
let input =
|
||||
@@ -2317,6 +2661,7 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-abc123");
|
||||
assert_eq!(data.agent_type, None);
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
|
||||
}
|
||||
|
||||
@@ -2328,6 +2673,7 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-xyz789");
|
||||
assert_eq!(data.agent_type, None);
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
@@ -2347,9 +2693,34 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-test");
|
||||
assert_eq!(data.agent_type, None);
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_agent_type() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, agent_type=general-purpose, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-abc123");
|
||||
assert_eq!(data.agent_type, Some("general-purpose".to_string()));
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_explore_type() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, agent_type=Explore, parent_tool_use_id=None, session_id=456"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-xyz789");
|
||||
assert_eq!(data.agent_type, Some("Explore".to_string()));
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
// SubagentStop hook parsing tests
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_parent() {
|
||||
@@ -2655,4 +3026,169 @@ mod tests {
|
||||
let exactly_at = Duration::from_secs(300);
|
||||
assert!(exactly_at >= STUCK_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending_since_reset_on_assistant_message_simulates_long_response() {
|
||||
// Regression test: an Assistant message arriving during a long multi-step response
|
||||
// (e.g. subagents, chained tool calls) must reset pending_since to Instant::now()
|
||||
// so the watchdog timer measures silence since the *last Claude activity*, not the
|
||||
// total wall-clock time since the user's message was sent.
|
||||
let pending_since: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
|
||||
|
||||
// User sends a message — watchdog timer starts
|
||||
*pending_since.lock() = Some(Instant::now());
|
||||
let original_instant = (*pending_since.lock()).unwrap();
|
||||
|
||||
// Simulate some time passing before Claude first responds (not enough to sleep in tests,
|
||||
// but we verify the reset logic by recording the original instant and confirming it
|
||||
// is replaced after an Assistant message arrives).
|
||||
// In production this represents minutes of subagent work.
|
||||
|
||||
// Assistant message arrives — timer must be reset, not cleared
|
||||
*pending_since.lock() = Some(Instant::now());
|
||||
|
||||
let after_reset = (*pending_since.lock()).unwrap();
|
||||
|
||||
// Still Some (watchdog still active until Result arrives)
|
||||
assert!(pending_since.lock().is_some(), "pending_since must remain Some after an Assistant message");
|
||||
|
||||
// The reset instant must be >= the original (monotonic clock)
|
||||
assert!(
|
||||
after_reset >= original_instant,
|
||||
"reset instant should be at least as recent as the original"
|
||||
);
|
||||
|
||||
// Elapsed since reset is tiny — watchdog would NOT fire
|
||||
let elapsed_since_reset = after_reset.elapsed();
|
||||
assert!(
|
||||
elapsed_since_reset < Duration::from_secs(1),
|
||||
"elapsed since reset should be under 1 second in tests"
|
||||
);
|
||||
|
||||
// Final Result clears it entirely
|
||||
*pending_since.lock() = None;
|
||||
assert!(pending_since.lock().is_none(), "pending_since cleared on Result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_worktree_hook_create_with_all_fields() {
|
||||
let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=123"#;
|
||||
let result = parse_worktree_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.name, "worktree-abc");
|
||||
assert_eq!(info.path, "/tmp/worktrees/worktree-abc");
|
||||
assert_eq!(info.branch, "feat/my-feature");
|
||||
assert_eq!(info.original_repo_directory, "/home/naomi/code/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_worktree_hook_remove_with_all_fields() {
|
||||
let line = r#"[WorktreeRemove Hook] name=worktree-xyz, path=/tmp/worktrees/worktree-xyz, branch=fix/bug-123, original_repo_directory=/home/naomi/code/other, session_id=456"#;
|
||||
let result = parse_worktree_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.name, "worktree-xyz");
|
||||
assert_eq!(info.branch, "fix/bug-123");
|
||||
assert_eq!(info.original_repo_directory, "/home/naomi/code/other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_worktree_hook_missing_field_returns_none() {
|
||||
// Missing branch field — should return None
|
||||
let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, original_repo_directory=/home/naomi/code/project, session_id=123"#;
|
||||
let result = parse_worktree_hook(line);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_worktree_hook_invalid_returns_none() {
|
||||
let line = "[WorktreeCreate Hook] no structured data here";
|
||||
let result = parse_worktree_hook(line);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// Build the auto-memory settings JSON without executing a command (for testing)
|
||||
#[cfg(test)]
|
||||
fn build_auto_memory_settings_arg(dir: &str) -> String {
|
||||
format!(r#"{{"autoMemoryDirectory":"{}"}}"#, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_auto_memory_settings_structure() {
|
||||
let settings_json = build_auto_memory_settings_arg("/custom/memory/dir");
|
||||
assert_eq!(
|
||||
settings_json,
|
||||
r#"{"autoMemoryDirectory":"/custom/memory/dir"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_auto_memory_settings_empty_path_skipped() {
|
||||
let dir = "";
|
||||
assert!(dir.is_empty(), "Empty directory should be skipped");
|
||||
}
|
||||
|
||||
/// Build the combined settings JSON for both memory directory and model overrides (for testing)
|
||||
#[cfg(test)]
|
||||
fn build_combined_settings_arg(
|
||||
memory_dir: Option<&str>,
|
||||
model_overrides: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> String {
|
||||
let mut settings = serde_json::Map::new();
|
||||
if let Some(dir) = memory_dir {
|
||||
if !dir.is_empty() {
|
||||
settings.insert(
|
||||
"autoMemoryDirectory".to_string(),
|
||||
serde_json::Value::String(dir.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(overrides) = model_overrides {
|
||||
if !overrides.is_empty() {
|
||||
if let Ok(val) = serde_json::to_value(overrides) {
|
||||
settings.insert("modelOverrides".to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&settings).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_combined_settings_memory_only() {
|
||||
let result = build_combined_settings_arg(Some("/custom/dir"), None);
|
||||
assert_eq!(result, r#"{"autoMemoryDirectory":"/custom/dir"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_combined_settings_overrides_only() {
|
||||
let mut overrides = std::collections::HashMap::new();
|
||||
overrides.insert(
|
||||
"claude-opus-4-6".to_string(),
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
|
||||
);
|
||||
let result = build_combined_settings_arg(None, Some(&overrides));
|
||||
assert!(result.contains("modelOverrides"));
|
||||
assert!(result.contains("claude-opus-4-6"));
|
||||
assert!(result.contains("arn:aws:bedrock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_combined_settings_both_fields() {
|
||||
let mut overrides = std::collections::HashMap::new();
|
||||
overrides.insert("claude-opus-4-6".to_string(), "custom-model-id".to_string());
|
||||
let result = build_combined_settings_arg(Some("/mem/dir"), Some(&overrides));
|
||||
assert!(result.contains("autoMemoryDirectory"));
|
||||
assert!(result.contains("modelOverrides"));
|
||||
assert!(result.contains("/mem/dir"));
|
||||
assert!(result.contains("custom-model-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_combined_settings_empty_produces_empty_object() {
|
||||
let result = build_combined_settings_arg(Some(""), None);
|
||||
assert_eq!(result, "{}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user