chore: CLI v2.1.75–v2.1.80 audit and support (#223–#232) (#233)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## Summary

This PR implements all tickets filed from the CLI v2.1.74 → v2.1.80 changelog audit (issues #223–#232).

### Changes by Issue

- **#223** — `feat: handle Elicitation and ElicitationResult hook events`
  New `ElicitationModal.svelte` component, Rust parsing for `[Elicitation Hook]` and `[ElicitationResult Hook]`, new store methods, and TypeScript event types.

- **#224** — `feat: handle StopFailure hook event for API error turns`
  Rust parsing for `[StopFailure Hook]`; frontend shows error toast + error character state.

- **#225** — `feat: handle PostCompact hook event`
  Rust parsing for `[PostCompact Hook]`; frontend shows info toast + success character state.

- **#226** — `feat: expose --name CLI flag as session name at startup`
  Added `session_name` field to `ClaudeStartOptions`; `StatusBar.doConnect()` passes the conversation name.

- **#227** — `fix: tighten startup watchdog and correct misleading comment`
  Startup watchdog tightened from 60 s → 30 s; corrected a comment that said "5 minutes" whilst the code used 60 seconds.

- **#228** — `fix: document cost estimation review and update default model fallback`
  Default model fallback updated from `claude-sonnet-4-5-20250929` → `claude-sonnet-4-6`; added doc comment explaining why char-based estimation is unaffected by v2.1.75 token overcounting fix.

- **#229** — `chore: update supported CLI version constant to 2.1.80`
  `SUPPORTED_CLI_VERSION` bumped in `CliVersion.svelte`.

- **#230** — `feat: surface memory file last-modified timestamps in MemoryBrowserPanel`
  Backend populates `last_modified` Unix timestamp; frontend formats and displays it per file.

- **#231** — `feat: update max_output_tokens upper bound and helper text for 128k`
  Input max raised to 128 000; placeholder and helper text updated to reflect model-dependent defaults and 128 k ceiling for Opus/Sonnet 4.6.

- **#232** — `fix: document non-streaming fallback compatibility with mid-session watchdog`
  Added doc comment above `STUCK_TIMEOUT` explaining the 5-minute watchdog is intentionally larger than the CLI's 2-minute non-streaming API fallback.

---

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #233
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #233.
This commit is contained in:
2026-03-23 14:28:08 -07:00
committed by Naomi Carrigan
parent 8220ab6b85
commit 4134e11c88
21 changed files with 1075 additions and 22 deletions
+17 -3
View File
@@ -1464,6 +1464,7 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
pub struct MemoryFileInfo {
pub path: String,
pub heading: Option<String>,
pub last_modified: Option<String>, // Unix timestamp in seconds as a string
}
#[derive(serde::Serialize)]
@@ -1535,7 +1536,11 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
let mut files = Vec::new();
for path in paths {
let heading = read_wsl_file_first_heading(&path);
files.push(MemoryFileInfo { path, heading });
files.push(MemoryFileInfo {
path,
heading,
last_modified: None,
});
}
Ok(MemoryFilesResponse { files })
@@ -1605,14 +1610,23 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
// Sort files alphabetically
memory_paths.sort();
// Read first heading from each file
// Read first heading and modification time from each file
let files = memory_paths
.into_iter()
.map(|path| {
let heading = fs::read_to_string(&path)
.ok()
.and_then(|content| extract_first_heading(&content));
MemoryFileInfo { path, heading }
let last_modified = fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs().to_string());
MemoryFileInfo {
path,
heading,
last_modified,
}
})
.collect();
+3
View File
@@ -51,6 +51,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub model_overrides: Option<HashMap<String, String>>,
#[serde(default)]
pub session_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+175
View File
@@ -280,6 +280,44 @@ pub struct UserQuestionEvent {
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElicitationEvent {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElicitationResultEvent {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopFailureEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostCompactEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStartEvent {
pub tool_use_id: String,
@@ -566,4 +604,141 @@ mod tests {
panic!("Expected RateLimitEvent variant");
}
}
#[test]
fn test_elicitation_event_serialization() {
let event = ElicitationEvent {
message: "Please provide the API endpoint".to_string(),
server_name: Some("my-server".to_string()),
request_id: Some("req-123".to_string()),
conversation_id: Some("conv-abc".to_string()),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"message\":\"Please provide the API endpoint\""));
assert!(serialized.contains("\"server_name\":\"my-server\""));
assert!(serialized.contains("\"request_id\":\"req-123\""));
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
}
#[test]
fn test_elicitation_event_omits_none_fields() {
let event = ElicitationEvent {
message: "Enter your token".to_string(),
server_name: None,
request_id: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"message\":\"Enter your token\""));
assert!(!serialized.contains("server_name"));
assert!(!serialized.contains("request_id"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_elicitation_result_event_serialization() {
let event = ElicitationResultEvent {
action: "accept".to_string(),
request_id: Some("req-123".to_string()),
conversation_id: Some("conv-abc".to_string()),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"action\":\"accept\""));
assert!(serialized.contains("\"request_id\":\"req-123\""));
}
#[test]
fn test_elicitation_result_event_cancel_omits_none_fields() {
let event = ElicitationResultEvent {
action: "cancel".to_string(),
request_id: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"action\":\"cancel\""));
assert!(!serialized.contains("request_id"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_stop_failure_event_serialization() {
let event = StopFailureEvent {
stop_reason: Some("api_error".to_string()),
error_type: Some("rate_limit".to_string()),
conversation_id: Some("conv-abc".to_string()),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
assert!(serialized.contains("\"error_type\":\"rate_limit\""));
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
}
#[test]
fn test_stop_failure_event_omits_none_fields() {
let event = StopFailureEvent {
stop_reason: None,
error_type: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(!serialized.contains("stop_reason"));
assert!(!serialized.contains("error_type"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_stop_failure_event_partial_fields() {
let event = StopFailureEvent {
stop_reason: Some("api_error".to_string()),
error_type: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
assert!(!serialized.contains("error_type"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_post_compact_event_serialization() {
let event = PostCompactEvent {
session_id: Some("sess-abc".to_string()),
conversation_id: Some("conv-123".to_string()),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"session_id\":\"sess-abc\""));
assert!(serialized.contains("\"conversation_id\":\"conv-123\""));
}
#[test]
fn test_post_compact_event_omits_none_fields() {
let event = PostCompactEvent {
session_id: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(!serialized.contains("session_id"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_post_compact_event_partial_fields() {
let event = PostCompactEvent {
session_id: Some("sess-xyz".to_string()),
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
assert!(!serialized.contains("conversation_id"));
}
}
+458 -9
View File
@@ -15,9 +15,10 @@ use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent,
UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
};
use parking_lot::RwLock;
use std::cell::RefCell;
@@ -286,6 +287,13 @@ impl WslBridge {
}
}
// Pass session name to Claude Code if specified
if let Some(ref name) = options.session_name {
if !name.is_empty() {
cmd.args(["--name", name]);
}
}
// Add worktree flag if requested
if options.use_worktree {
cmd.arg("--worktree");
@@ -465,6 +473,14 @@ impl WslBridge {
}
}
// Pass session name to Claude Code if specified
if let Some(ref name) = options.session_name {
if !name.is_empty() {
let escaped = name.replace('\'', "'\\''");
claude_cmd.push_str(&format!(" --name '{}'", escaped));
}
}
// Add worktree flag if requested
if options.use_worktree {
claude_cmd.push_str(" --worktree");
@@ -587,12 +603,13 @@ impl WslBridge {
);
// Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash
// after spawning). After 5 minutes we kill it so the user isn't stuck forever.
// handle_stdout will surface the error when stdout closes after the kill.
// after spawning). After 30 seconds we kill it so the user is not stuck forever.
// The CLI v2.1.79 stdin-hang fix means startup is reliably fast; 30 s is a generous
// safety net rather than a workaround for a known hang.
let process_watchdog = self.process.clone();
let received_init_watchdog = self.received_init.clone();
thread::spawn(move || {
thread::sleep(Duration::from_secs(60));
thread::sleep(Duration::from_secs(30));
if !received_init_watchdog.load(Ordering::SeqCst) {
if let Some(mut proc) = process_watchdog.lock().take() {
let _ = proc.kill();
@@ -613,6 +630,9 @@ impl WslBridge {
let process_mid_watchdog = self.process.clone();
let pending_since_watchdog = self.pending_since.clone();
let generation_watchdog = self.watchdog_generation.clone();
// 5-minute stuck timeout is intentionally larger than the CLI's 2-minute per-attempt
// non-streaming fallback (added in v2.1.79). A non-streaming response will either
// succeed or fail with a Result message well within this window.
const STUCK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const POLL_INTERVAL: Duration = Duration::from_secs(30);
thread::spawn(move || {
@@ -783,14 +803,17 @@ impl WslBridge {
}
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string());
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-6".to_string());
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
// Char-based estimation: 3.5 chars/token is intentionally conservative (standard English
// averages ~4 chars/token). CLI v2.1.75 fixed over-counting in Claude Code's compaction
// logic but does not affect this heuristic — we count characters ourselves, independently
// of Claude Code's internal token tracking. The 1.2 safety margin avoids undercharging.
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64)
+ ((thinking_chars as f64 / 3.5).ceil() as u64);
@@ -1051,11 +1074,21 @@ fn handle_stderr(
// Hook events are informational — emit with distinct types instead of error
let is_worktree_create = line.contains("[WorktreeCreate Hook]");
let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
let is_elicitation = line.contains("[Elicitation Hook]");
let is_elicitation_result = line.contains("[ElicitationResult Hook]");
let is_stop_failure = line.contains("[StopFailure Hook]");
let is_post_compact = line.contains("[PostCompact Hook]");
let line_type = if is_worktree_create || is_worktree_remove {
"worktree"
} else if line.contains("[ConfigChange Hook]") {
"config-change"
} else if is_elicitation || is_elicitation_result {
"elicitation"
} else if is_stop_failure {
"error"
} else if is_post_compact {
"compact-prompt"
} else {
"error"
};
@@ -1097,6 +1130,103 @@ fn handle_stderr(
parent_tool_use_id: None,
},
);
} else if is_elicitation {
let data = parse_elicitation_hook(&line);
let friendly_content =
format!("MCP server requesting input: {}", data.message);
let _ = app.emit(
"claude:elicitation",
ElicitationEvent {
message: data.message,
server_name: data.server_name,
request_id: data.request_id,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "elicitation".to_string(),
content: friendly_content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else if is_elicitation_result {
let data = parse_elicitation_result_hook(&line);
let friendly_content =
format!("MCP elicitation completed: {}", data.action);
let _ = app.emit(
"claude:elicitation-result",
ElicitationResultEvent {
action: data.action,
request_id: data.request_id,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "elicitation".to_string(),
content: friendly_content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else if is_stop_failure {
let data = parse_stop_failure_hook(&line);
let friendly_content = build_stop_failure_message(&data);
let _ = app.emit(
"claude:stop-failure",
StopFailureEvent {
stop_reason: data.stop_reason,
error_type: data.error_type,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: friendly_content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else if is_post_compact {
let data = parse_post_compact_hook(&line);
let _ = app.emit(
"claude:post-compact",
PostCompactEvent {
session_id: data.session_id,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "compact-prompt".to_string(),
content: "Context compacted — conversation history has been summarised to free up space.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else {
let _ = app.emit(
"claude:output",
@@ -1235,6 +1365,111 @@ fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
})
}
#[derive(Debug)]
struct ElicitationData {
message: String,
server_name: Option<String>,
request_id: Option<String>,
}
fn parse_elicitation_hook(line: &str) -> ElicitationData {
let message = extract_quoted_value(line, "message").unwrap_or_else(|| {
line.split("[Elicitation Hook]")
.nth(1)
.unwrap_or("")
.trim()
.to_string()
});
let server_name = extract_debug_string_value(line, "server_name");
let request_id = extract_debug_string_value(line, "request_id");
ElicitationData { message, server_name, request_id }
}
#[derive(Debug)]
struct ElicitationResultData {
action: String,
request_id: Option<String>,
}
fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData {
let action =
extract_quoted_value(line, "action").unwrap_or_else(|| "unknown".to_string());
let request_id = extract_debug_string_value(line, "request_id");
ElicitationResultData { action, request_id }
}
#[derive(Debug)]
struct StopFailureData {
stop_reason: Option<String>,
error_type: Option<String>,
}
fn parse_stop_failure_hook(line: &str) -> StopFailureData {
let stop_reason = extract_quoted_value(line, "stop_reason");
let error_type = extract_debug_string_value(line, "error_type");
StopFailureData { stop_reason, error_type }
}
/// Builds a user-friendly message from a `StopFailureData` instance.
fn build_stop_failure_message(data: &StopFailureData) -> String {
match data.stop_reason.as_deref() {
Some("rate_limit") => "Session stopped: rate limit reached".to_string(),
Some("auth_failure") | Some("authentication") => {
"Session stopped: authentication failed".to_string()
}
Some(reason) => format!("Session stopped due to API error: {}", reason),
None => match data.error_type.as_deref() {
Some(et) => format!("Session stopped due to API error: {}", et),
None => "Session stopped due to an unknown API error".to_string(),
},
}
}
#[derive(Debug)]
struct PostCompactData {
session_id: Option<String>,
}
fn parse_post_compact_hook(line: &str) -> PostCompactData {
let session_id = extract_debug_string_value(line, "session_id");
PostCompactData { session_id }
}
/// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
/// Handles escape sequences within the quoted value.
fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
let prefix = format!("{}=\"", key);
let start_idx = line.find(&prefix)? + prefix.len();
let rest = &line[start_idx..];
let mut result = String::new();
let mut chars = rest.chars();
loop {
match chars.next() {
Some('"') => return Some(result),
Some('\\') => match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some(c) => {
result.push('\\');
result.push(c);
}
None => break,
},
Some(c) => result.push(c),
None => break,
}
}
None
}
/// Extract text content from a ToolResult's `content` field.
/// The content may be a JSON string or an array of typed content blocks.
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
@@ -1356,7 +1591,7 @@ fn process_json_line(
stats_guard.model.clone()
}).unwrap_or_else(|| {
tracing::warn!("No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
"claude-sonnet-4-6".to_string()
});
// Calculate cost for historical tracking (including cache tokens)
@@ -3191,4 +3426,218 @@ mod tests {
let result = build_combined_settings_arg(Some(""), None);
assert_eq!(result, "{}");
}
#[test]
fn test_extract_quoted_value_basic() {
let line = r#"[Elicitation Hook] message="Hello world", server_name=Some("srv")"#;
let result = extract_quoted_value(line, "message");
assert_eq!(result, Some("Hello world".to_string()));
}
#[test]
fn test_extract_quoted_value_with_escapes() {
let line = r#"[Elicitation Hook] message="Line one\nLine two", request_id=Some("r1")"#;
let result = extract_quoted_value(line, "message");
assert_eq!(result, Some("Line one\nLine two".to_string()));
}
#[test]
fn test_extract_quoted_value_missing() {
let line = r#"[Elicitation Hook] server_name=Some("srv")"#;
let result = extract_quoted_value(line, "message");
assert_eq!(result, None);
}
#[test]
fn test_parse_elicitation_hook_with_all_fields() {
let line = r#"[Elicitation Hook] message="Please enter your API key", server_name=Some("my-mcp"), request_id=Some("req-456")"#;
let data = parse_elicitation_hook(line);
assert_eq!(data.message, "Please enter your API key");
assert_eq!(data.server_name, Some("my-mcp".to_string()));
assert_eq!(data.request_id, Some("req-456".to_string()));
}
#[test]
fn test_parse_elicitation_hook_missing_optional_fields() {
let line = r#"[Elicitation Hook] message="What is the endpoint?", server_name=None, request_id=None"#;
let data = parse_elicitation_hook(line);
assert_eq!(data.message, "What is the endpoint?");
assert_eq!(data.server_name, None);
assert_eq!(data.request_id, None);
}
#[test]
fn test_parse_elicitation_hook_invalid_line() {
let line = "[Elicitation Hook] some unstructured data";
let data = parse_elicitation_hook(line);
assert_eq!(data.message, "some unstructured data");
assert_eq!(data.server_name, None);
assert_eq!(data.request_id, None);
}
#[test]
fn test_parse_elicitation_result_hook_accept() {
let line = r#"[ElicitationResult Hook] action="accept", request_id=Some("req-789")"#;
let data = parse_elicitation_result_hook(line);
assert_eq!(data.action, "accept");
assert_eq!(data.request_id, Some("req-789".to_string()));
}
#[test]
fn test_parse_elicitation_result_hook_cancel() {
let line = r#"[ElicitationResult Hook] action="cancel", request_id=None"#;
let data = parse_elicitation_result_hook(line);
assert_eq!(data.action, "cancel");
assert_eq!(data.request_id, None);
}
#[test]
fn test_parse_stop_failure_hook_with_all_fields() {
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=Some("rate_limit"), conversation_id=Some("conv-123")"#;
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, Some("api_error".to_string()));
assert_eq!(data.error_type, Some("rate_limit".to_string()));
}
#[test]
fn test_parse_stop_failure_hook_missing_optional_error_type() {
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=None"#;
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, Some("api_error".to_string()));
assert_eq!(data.error_type, None);
}
#[test]
fn test_parse_stop_failure_hook_invalid_line() {
let line = "[StopFailure Hook] some unstructured data";
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, None);
assert_eq!(data.error_type, None);
}
#[test]
fn test_build_stop_failure_message_rate_limit() {
let data = StopFailureData {
stop_reason: Some("rate_limit".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: rate limit reached");
}
#[test]
fn test_build_stop_failure_message_auth_failure() {
let data = StopFailureData {
stop_reason: Some("auth_failure".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
}
#[test]
fn test_build_stop_failure_message_authentication() {
let data = StopFailureData {
stop_reason: Some("authentication".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
}
#[test]
fn test_build_stop_failure_message_unknown_reason() {
let data = StopFailureData {
stop_reason: Some("server_error".to_string()),
error_type: None,
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to API error: server_error"
);
}
#[test]
fn test_build_stop_failure_message_no_reason_with_error_type() {
let data = StopFailureData {
stop_reason: None,
error_type: Some("timeout".to_string()),
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to API error: timeout"
);
}
#[test]
fn test_parse_post_compact_hook_with_session_id() {
let line =
r#"[PostCompact Hook] session_id=Some("sess-abc123"), conversation_id=Some("conv-xyz")"#;
let data = parse_post_compact_hook(line);
assert_eq!(data.session_id, Some("sess-abc123".to_string()));
}
#[test]
fn test_parse_post_compact_hook_without_session_id() {
let line = "[PostCompact Hook] session_id=None";
let data = parse_post_compact_hook(line);
assert_eq!(data.session_id, None);
}
#[test]
fn test_parse_post_compact_hook_empty_line() {
let line = "[PostCompact Hook]";
let data = parse_post_compact_hook(line);
assert_eq!(data.session_id, None);
}
#[test]
fn test_build_stop_failure_message_no_fields() {
let data = StopFailureData {
stop_reason: None,
error_type: None,
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to an unknown API error"
);
}
/// Build the --name argument string without executing a command (for testing)
#[cfg(test)]
fn build_session_name_arg(name: &str) -> Option<(String, String)> {
if name.is_empty() {
return None;
}
Some(("--name".to_string(), name.to_string()))
}
#[test]
fn test_e2e_session_name_passed_when_set() {
let name = "Sakura";
let result = build_session_name_arg(name);
assert!(result.is_some());
let (flag, value) = result.unwrap();
assert_eq!(flag, "--name");
assert_eq!(value, "Sakura");
}
#[test]
fn test_e2e_session_name_not_passed_when_none() {
let options = ClaudeStartOptions {
session_name: None,
..Default::default()
};
// When session_name is None, no --name arg should be produced
let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false);
assert!(!has_name);
}
#[test]
fn test_e2e_session_name_not_passed_when_empty() {
let options = ClaudeStartOptions {
session_name: Some(String::new()),
..Default::default()
};
// When session_name is Some(""), no --name arg should be produced
let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false);
assert!(!has_name);
}
}