feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m21s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled

## 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:
2026-03-13 01:34:44 -07:00
committed by Naomi Carrigan
parent a690a4969b
commit 452fe185df
45 changed files with 2905 additions and 585 deletions
+121
View File
@@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
.collect())
}
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
let data: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No version field in response".to_string())
}
#[tauri::command]
pub async fn check_cli_latest_version() -> Result<String, String> {
let client = reqwest::Client::new();
let response = client
.get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest")
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch CLI version: {}", e))?;
if !response.status().is_success() {
return Err(format!("Registry returned status: {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_npm_cli_version(&body)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
@@ -2547,6 +2578,32 @@ pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
})
}
#[tauri::command]
pub async fn open_binary_file(app: AppHandle, path: String) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
{
// Convert the WSL Linux path (e.g. /tmp/file.pdf) to a Windows UNC path
// (e.g. \\wsl.localhost\Ubuntu\tmp\file.pdf) so the Windows shell can open it.
let output = std::process::Command::new("wsl")
.args(["wslpath", "-w", &path])
.output()
.map_err(|e| e.to_string())?;
let windows_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
app.opener()
.open_path(windows_path, None::<&str>)
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "windows"))]
{
app.opener()
.open_path(path, None::<&str>)
.map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2838,6 +2895,35 @@ mod tests {
assert!(json.contains("null") || json.contains("release_notes"));
}
// ==================== parse_npm_cli_version tests ====================
#[test]
fn test_parse_npm_cli_version_valid() {
let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#;
let result = parse_npm_cli_version(json).unwrap();
assert_eq!(result, "2.1.72");
}
#[test]
fn test_parse_npm_cli_version_missing_field() {
let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#;
let result = parse_npm_cli_version(json);
assert!(result.is_err());
}
#[test]
fn test_parse_npm_cli_version_invalid_json() {
let result = parse_npm_cli_version("not json at all");
assert!(result.is_err());
}
#[test]
fn test_parse_npm_cli_version_non_string_version() {
let json = r#"{"version":123}"#;
let result = parse_npm_cli_version(json);
assert!(result.is_err());
}
// ==================== SavedFileInfo struct tests ====================
#[test]
@@ -3232,4 +3318,39 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
Some("Indented Heading".to_string())
);
}
// ==================== open_binary_file E2E path conversion tests ====================
/// Build the wslpath command structure without executing it, for cross-platform CI testing.
#[cfg(test)]
fn build_wslpath_command(path: &str) -> (String, Vec<String>) {
(
"wsl".to_string(),
vec!["wslpath".to_string(), "-w".to_string(), path.to_string()],
)
}
#[test]
fn test_e2e_wslpath_command_structure_pdf() {
let (command, args) = build_wslpath_command("/tmp/mcp_output_abc123.pdf");
assert_eq!(command, "wsl");
assert_eq!(args.len(), 3);
assert_eq!(args[0], "wslpath");
assert_eq!(args[1], "-w");
assert_eq!(args[2], "/tmp/mcp_output_abc123.pdf");
}
#[test]
fn test_e2e_wslpath_command_structure_audio() {
let (command, args) = build_wslpath_command("/tmp/mcp_output_xyz789.mp3");
assert_eq!(command, "wsl");
assert_eq!(args[2], "/tmp/mcp_output_xyz789.mp3");
}
#[test]
fn test_e2e_wslpath_command_structure_preserves_path() {
let path = "/home/naomi/documents/report with spaces.pdf";
let (_, args) = build_wslpath_command(path);
assert_eq!(args[2], path);
}
}
+71
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -34,6 +36,21 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub disable_cron: bool,
#[serde(default = "default_include_git_instructions")]
pub include_git_instructions: bool,
#[serde(default = "default_enable_claudeai_mcp_servers")]
pub enable_claudeai_mcp_servers: bool,
#[serde(default)]
pub auto_memory_directory: Option<String>,
#[serde(default)]
pub model_overrides: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -168,6 +185,21 @@ pub struct HikariConfig {
#[serde(default)]
pub task_loop_include_summary: bool,
#[serde(default)]
pub disable_cron: bool,
#[serde(default = "default_include_git_instructions")]
pub include_git_instructions: bool,
#[serde(default = "default_enable_claudeai_mcp_servers")]
pub enable_claudeai_mcp_servers: bool,
#[serde(default)]
pub auto_memory_directory: Option<String>,
#[serde(default)]
pub model_overrides: Option<HashMap<String, String>>,
}
impl Default for HikariConfig {
@@ -214,6 +246,11 @@ impl Default for HikariConfig {
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat".to_string(),
task_loop_include_summary: false,
disable_cron: false,
include_git_instructions: true,
enable_claudeai_mcp_servers: true,
auto_memory_directory: None,
model_overrides: None,
}
}
}
@@ -258,6 +295,14 @@ fn default_task_loop_commit_prefix() -> String {
"feat".to_string()
}
fn default_include_git_instructions() -> bool {
true
}
fn default_enable_claudeai_mcp_servers() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
@@ -352,6 +397,11 @@ mod tests {
assert!(!config.task_loop_auto_commit);
assert_eq!(config.task_loop_commit_prefix, "feat");
assert!(!config.task_loop_include_summary);
assert!(!config.disable_cron);
assert!(config.include_git_instructions);
assert!(config.enable_claudeai_mcp_servers);
assert!(config.auto_memory_directory.is_none());
assert!(config.model_overrides.is_none());
}
#[test]
@@ -398,6 +448,14 @@ mod tests {
task_loop_auto_commit: true,
task_loop_commit_prefix: "fix".to_string(),
task_loop_include_summary: true,
disable_cron: true,
include_git_instructions: false,
enable_claudeai_mcp_servers: false,
auto_memory_directory: Some("/custom/memory".to_string()),
model_overrides: Some(HashMap::from([(
"claude-opus-4-6".to_string(),
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
)])),
};
let json = serde_json::to_string(&config).unwrap();
@@ -415,6 +473,19 @@ mod tests {
assert!(deserialized.task_loop_auto_commit);
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
assert!(deserialized.task_loop_include_summary);
assert!(deserialized.disable_cron);
assert!(!deserialized.include_git_instructions);
assert!(!deserialized.enable_claudeai_mcp_servers);
assert_eq!(
deserialized.auto_memory_directory,
Some("/custom/memory".to_string())
);
assert!(deserialized.model_overrides.is_some());
let overrides = deserialized.model_overrides.unwrap();
assert_eq!(
overrides.get("claude-opus-4-6").map(String::as_str),
Some("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1")
);
}
#[test]
+2
View File
@@ -134,6 +134,7 @@ pub fn run() {
list_skills,
check_for_updates,
fetch_changelog,
check_cli_latest_version,
save_temp_file,
register_temp_file,
get_temp_files,
@@ -222,6 +223,7 @@ pub fn run() {
delete_draft,
delete_all_drafts,
scan_project,
open_binary_file,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+20
View File
@@ -292,6 +292,26 @@ pub struct AgentStartEvent {
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeInfo {
pub name: String,
pub path: String,
pub branch: String,
pub original_repo_directory: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// "create" or "remove"
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<WorktreeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+561 -25
View File
@@ -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, "{}");
}
}