generated from nhcarrigan/template
Compare commits
2 Commits
1d94bdfbb0
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
34e9af57f0
|
|||
| bf411adeb7 |
+3
-3
@@ -36,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
|
|||||||
run_check "Frontend lint" "pnpm lint" || failed=1
|
run_check "Frontend lint" "pnpm lint" || failed=1
|
||||||
run_check "Frontend format check" "pnpm format:check" || failed=1
|
run_check "Frontend format check" "pnpm format:check" || failed=1
|
||||||
run_check "Frontend type check" "pnpm check" || failed=1
|
run_check "Frontend type check" "pnpm check" || failed=1
|
||||||
run_check "Frontend tests" "pnpm test" || failed=1
|
run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
|
||||||
|
|
||||||
# Backend checks
|
# Backend checks
|
||||||
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
|
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
|
||||||
run_check "Backend tests" "cargo test" || failed=1
|
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo -e "\n${YELLOW}========================================${NC}"
|
echo -e "\n${YELLOW}========================================${NC}"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
|
|||||||
let mut newly_unlocked = Vec::new();
|
let mut newly_unlocked = Vec::new();
|
||||||
let message_lower = message.to_lowercase();
|
let message_lower = message.to_lowercase();
|
||||||
|
|
||||||
println!("Checking message achievements for: {}", message);
|
tracing::info!("Checking message achievements for: {}", message);
|
||||||
|
|
||||||
// Relationship & Greetings
|
// Relationship & Greetings
|
||||||
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
||||||
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
|
|||||||
) -> Vec<AchievementId> {
|
) -> Vec<AchievementId> {
|
||||||
let mut newly_unlocked = Vec::new();
|
let mut newly_unlocked = Vec::new();
|
||||||
|
|
||||||
println!(
|
tracing::info!(
|
||||||
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
||||||
stats.messages_exchanged,
|
stats.messages_exchanged,
|
||||||
stats.total_input_tokens + stats.total_output_tokens,
|
stats.total_input_tokens + stats.total_output_tokens,
|
||||||
stats.code_blocks_generated
|
stats.code_blocks_generated
|
||||||
);
|
);
|
||||||
println!("Currently unlocked: {:?}", progress.unlocked);
|
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
|
||||||
|
|
||||||
// Token milestones
|
// Token milestones
|
||||||
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
||||||
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
||||||
println!("Unlocked FirstSteps achievement!");
|
tracing::info!("Unlocked FirstSteps achievement!");
|
||||||
newly_unlocked.push(AchievementId::FirstSteps);
|
newly_unlocked.push(AchievementId::FirstSteps);
|
||||||
}
|
}
|
||||||
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
||||||
@@ -2244,7 +2244,7 @@ pub async fn save_achievements(
|
|||||||
// Create a serializable version with just the unlocked achievement IDs
|
// Create a serializable version with just the unlocked achievement IDs
|
||||||
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
||||||
|
|
||||||
println!("Saving achievements: {:?}", unlocked_list);
|
tracing::info!("Saving achievements: {:?}", unlocked_list);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
"unlocked",
|
"unlocked",
|
||||||
@@ -2252,18 +2252,18 @@ pub async fn save_achievements(
|
|||||||
);
|
);
|
||||||
store.save().map_err(|e| e.to_string())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
println!("Achievements saved successfully");
|
tracing::info!("Achievements saved successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load achievements from persistent store
|
// Load achievements from persistent store
|
||||||
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||||
println!("Loading achievements from store...");
|
tracing::info!("Loading achievements from store...");
|
||||||
|
|
||||||
let store = match app.store("achievements.json") {
|
let store = match app.store("achievements.json") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to open achievements store: {}", e);
|
tracing::error!("Failed to open achievements store: {}", e);
|
||||||
return AchievementProgress::new();
|
return AchievementProgress::new();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2272,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
|||||||
|
|
||||||
// Get unlocked achievements
|
// Get unlocked achievements
|
||||||
if let Some(unlocked_value) = store.get("unlocked") {
|
if let Some(unlocked_value) = store.get("unlocked") {
|
||||||
println!("Found unlocked value in store: {:?}", unlocked_value);
|
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
|
||||||
if let Ok(unlocked_list) =
|
if let Ok(unlocked_list) =
|
||||||
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
||||||
{
|
{
|
||||||
println!("Loaded {} achievements", unlocked_list.len());
|
tracing::info!("Loaded {} achievements", unlocked_list.len());
|
||||||
for achievement_id in unlocked_list {
|
for achievement_id in unlocked_list {
|
||||||
progress.unlocked.insert(achievement_id);
|
progress.unlocked.insert(achievement_id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("Failed to parse unlocked achievements");
|
tracing::error!("Failed to parse unlocked achievements");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("No unlocked achievements found in store");
|
tracing::info!("No unlocked achievements found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
progress
|
progress
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub struct ClaudeStartOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct HikariConfig {
|
pub struct HikariConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl DiscordRpcManager {
|
|||||||
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
|
if let Err(e) = std::fs::create_dir_all(&app_data_dir) {
|
||||||
eprintln!("Failed to create app data directory: {}", e);
|
tracing::error!("Failed to create app data directory: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let log_path = app_data_dir.join("hikari_discord_rpc.log");
|
let log_path = app_data_dir.join("hikari_discord_rpc.log");
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ pub fn run() {
|
|||||||
.manage(discord_rpc.clone())
|
.manage(discord_rpc.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
// Initialize tracing with custom layer that emits to frontend
|
// Initialize tracing with custom layer that emits to frontend
|
||||||
|
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
|
||||||
|
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
|
||||||
let tauri_layer = TauriLogLayer::new(app.handle().clone());
|
let tauri_layer = TauriLogLayer::new(app.handle().clone());
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::fmt::layer())
|
|
||||||
.with(tauri_layer)
|
.with(tauri_layer)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ pub fn run() {
|
|||||||
|
|
||||||
// Set up system tray
|
// Set up system tray
|
||||||
if let Err(e) = setup_tray(app.handle()) {
|
if let Err(e) = setup_tray(app.handle()) {
|
||||||
eprintln!("Failed to set up system tray: {}", e);
|
tracing::error!("Failed to set up system tray: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window close event for minimize to tray and close confirmation
|
// Handle window close event for minimize to tray and close confirmation
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
|||||||
|
|
||||||
let persisted = PersistedStats::from(stats);
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
println!("Saving stats: {:?}", persisted);
|
tracing::info!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
"lifetime_stats",
|
"lifetime_stats",
|
||||||
@@ -626,32 +626,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
|||||||
);
|
);
|
||||||
store.save().map_err(|e| e.to_string())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
println!("Stats saved successfully");
|
tracing::info!("Stats saved successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load lifetime stats from persistent store
|
/// Load lifetime stats from persistent store
|
||||||
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||||
println!("Loading stats from store...");
|
tracing::info!("Loading stats from store...");
|
||||||
|
|
||||||
let store = match app.store("stats.json") {
|
let store = match app.store("stats.json") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to open stats store: {}", e);
|
tracing::error!("Failed to open stats store: {}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(stats_value) = store.get("lifetime_stats") {
|
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||||
println!("Found lifetime stats in store: {:?}", stats_value);
|
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
|
||||||
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||||
println!("Loaded lifetime stats successfully");
|
tracing::info!("Loaded lifetime stats successfully");
|
||||||
return Some(persisted);
|
return Some(persisted);
|
||||||
} else {
|
} else {
|
||||||
println!("Failed to parse lifetime stats");
|
tracing::error!("Failed to parse lifetime stats");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("No lifetime stats found in store");
|
tracing::info!("No lifetime stats found in store");
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ impl TempFileManager {
|
|||||||
for file_path in files {
|
for file_path in files {
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
if let Err(e) = fs::remove_file(&file_path) {
|
if let Err(e) = fs::remove_file(&file_path) {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"Warning: Failed to remove temp file {:?}: {}",
|
"Failed to remove temp file {:?}: {}",
|
||||||
file_path, e
|
file_path, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ impl TempFileManager {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() && !tracked_files.contains(&path) {
|
if path.is_file() && !tracked_files.contains(&path) {
|
||||||
if let Err(e) = fs::remove_file(&path) {
|
if let Err(e) = fs::remove_file(&path) {
|
||||||
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
|
||||||
} else {
|
} else {
|
||||||
cleaned_count += 1;
|
cleaned_count += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-47
@@ -133,21 +133,21 @@ impl WslBridge {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("Loading saved achievements...");
|
tracing::info!("Loading saved achievements...");
|
||||||
let achievements = crate::achievements::load_achievements(&app_clone).await;
|
let achievements = crate::achievements::load_achievements(&app_clone).await;
|
||||||
println!(
|
tracing::info!(
|
||||||
"Loaded {} unlocked achievements",
|
"Loaded {} unlocked achievements",
|
||||||
achievements.unlocked.len()
|
achievements.unlocked.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Loading saved stats...");
|
tracing::info!("Loading saved stats...");
|
||||||
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
||||||
|
|
||||||
let mut stats_guard = stats.write();
|
let mut stats_guard = stats.write();
|
||||||
stats_guard.achievements = achievements;
|
stats_guard.achievements = achievements;
|
||||||
|
|
||||||
if let Some(persisted) = persisted_stats {
|
if let Some(persisted) = persisted_stats {
|
||||||
println!("Applying persisted lifetime stats");
|
tracing::info!("Applying persisted lifetime stats");
|
||||||
stats_guard.apply_persisted(persisted);
|
stats_guard.apply_persisted(persisted);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -189,8 +189,8 @@ impl WslBridge {
|
|||||||
|
|
||||||
// Detect if we're running inside WSL or on Windows
|
// Detect if we're running inside WSL or on Windows
|
||||||
let is_wsl = detect_wsl();
|
let is_wsl = detect_wsl();
|
||||||
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
|
tracing::debug!("is_wsl: {}", is_wsl);
|
||||||
eprintln!("[DEBUG] options: {:?}", options);
|
tracing::debug!("options: {:?}", options);
|
||||||
|
|
||||||
let mut command = if is_wsl {
|
let mut command = if is_wsl {
|
||||||
// Running inside WSL - call claude directly
|
// Running inside WSL - call claude directly
|
||||||
@@ -199,8 +199,8 @@ impl WslBridge {
|
|||||||
"Could not find claude binary. Is Claude Code installed?".to_string()
|
"Could not find claude binary. Is Claude Code installed?".to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
eprintln!("[DEBUG] Found claude at: {}", claude_path);
|
tracing::debug!("Found claude at: {}", claude_path);
|
||||||
eprintln!("[DEBUG] Working dir: {}", working_dir);
|
tracing::debug!("Working dir: {}", working_dir);
|
||||||
|
|
||||||
let mut cmd = Command::new(&claude_path);
|
let mut cmd = Command::new(&claude_path);
|
||||||
cmd.args([
|
cmd.args([
|
||||||
@@ -256,7 +256,7 @@ impl WslBridge {
|
|||||||
cmd
|
cmd
|
||||||
} else {
|
} else {
|
||||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||||
eprintln!("[DEBUG] Windows path - using wsl");
|
tracing::debug!("Windows path - using wsl");
|
||||||
let mut cmd = Command::new("wsl");
|
let mut cmd = Command::new("wsl");
|
||||||
|
|
||||||
// Build the claude command with all arguments
|
// Build the claude command with all arguments
|
||||||
@@ -322,7 +322,7 @@ impl WslBridge {
|
|||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
let mut child = command.spawn().map_err(|e| {
|
let mut child = command.spawn().map_err(|e| {
|
||||||
eprintln!("[DEBUG] Spawn error: {:?}", e);
|
tracing::error!("Spawn error: {:?}", e);
|
||||||
format!("Failed to spawn process: {}", e)
|
format!("Failed to spawn process: {}", e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -500,7 +500,7 @@ impl WslBridge {
|
|||||||
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
|
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("[COST ESTIMATION] Estimating cost for interrupted request");
|
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request");
|
||||||
|
|
||||||
// Use conservative 3.5 chars/token for estimation (vs standard 4)
|
// Use conservative 3.5 chars/token for estimation (vs standard 4)
|
||||||
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
|
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
|
||||||
@@ -518,7 +518,7 @@ impl WslBridge {
|
|||||||
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
|
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
|
||||||
/ tool_stats.call_count;
|
/ tool_stats.call_count;
|
||||||
tool_overhead_tokens += avg_tokens;
|
tool_overhead_tokens += avg_tokens;
|
||||||
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
|
tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,9 +532,9 @@ impl WslBridge {
|
|||||||
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
|
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
|
||||||
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
|
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
|
||||||
|
|
||||||
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
|
tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
|
||||||
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
|
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
|
||||||
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
|
tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
|
||||||
output_chars + thinking_chars,
|
output_chars + thinking_chars,
|
||||||
estimated_output_tokens, conservative_output);
|
estimated_output_tokens, conservative_output);
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ impl WslBridge {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
|
tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
|
||||||
|
|
||||||
// Add to stats with estimated flag
|
// Add to stats with estimated flag
|
||||||
{
|
{
|
||||||
@@ -587,11 +587,11 @@ impl WslBridge {
|
|||||||
let stats_snapshot = self.stats.read().clone();
|
let stats_snapshot = self.stats.read().clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("Saving stats on session stop...");
|
tracing::info!("Saving stats on session stop...");
|
||||||
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
||||||
eprintln!("Failed to save stats: {}", e);
|
tracing::error!("Failed to save stats: {}", e);
|
||||||
} else {
|
} else {
|
||||||
println!("Stats saved successfully on session stop");
|
tracing::info!("Stats saved successfully on session stop");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,11 +636,11 @@ fn handle_stdout(
|
|||||||
match line {
|
match line {
|
||||||
Ok(line) if !line.is_empty() => {
|
Ok(line) if !line.is_empty() => {
|
||||||
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
||||||
eprintln!("Error processing line: {}", e);
|
tracing::error!("Error processing line: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error reading stdout: {}", e);
|
tracing::error!("Error reading stdout: {}", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -663,7 +663,7 @@ fn handle_stderr(
|
|||||||
// Check if this is a SubagentStart hook message
|
// Check if this is a SubagentStart hook message
|
||||||
if line.contains("[SubagentStart Hook]") {
|
if line.contains("[SubagentStart Hook]") {
|
||||||
if let Some(agent_data) = parse_subagent_start_hook(&line) {
|
if let Some(agent_data) = parse_subagent_start_hook(&line) {
|
||||||
eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}",
|
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
|
||||||
agent_data.agent_id, agent_data.parent_tool_use_id);
|
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
|
||||||
@@ -815,7 +815,7 @@ fn process_json_line(
|
|||||||
let stats_guard = stats.read();
|
let stats_guard = stats.read();
|
||||||
stats_guard.model.clone()
|
stats_guard.model.clone()
|
||||||
}).unwrap_or_else(|| {
|
}).unwrap_or_else(|| {
|
||||||
println!("[WARNING] No model info available for cost calculation, using default");
|
tracing::warn!("No model info available for cost calculation, using default");
|
||||||
"claude-sonnet-4-5-20250929".to_string()
|
"claude-sonnet-4-5-20250929".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -828,7 +828,7 @@ fn process_json_line(
|
|||||||
usage.cache_read_input_tokens,
|
usage.cache_read_input_tokens,
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
|
tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
|
||||||
usage.input_tokens,
|
usage.input_tokens,
|
||||||
usage.output_tokens,
|
usage.output_tokens,
|
||||||
usage.cache_creation_input_tokens,
|
usage.cache_creation_input_tokens,
|
||||||
@@ -917,8 +917,8 @@ fn process_json_line(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
||||||
eprintln!(
|
tracing::debug!(
|
||||||
"[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
|
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
|
||||||
id, description, subagent_type, parent_tool_use_id
|
id, description, subagent_type, parent_tool_use_id
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1063,6 +1063,13 @@ fn process_json_line(
|
|||||||
duration_ms,
|
duration_ms,
|
||||||
num_turns,
|
num_turns,
|
||||||
} => {
|
} => {
|
||||||
|
tracing::info!(
|
||||||
|
"Received Result message: subtype={}, has_denials={}, denial_count={:?}",
|
||||||
|
subtype,
|
||||||
|
permission_denials.is_some(),
|
||||||
|
permission_denials.as_ref().map(|d| d.len())
|
||||||
|
);
|
||||||
|
|
||||||
let state = if subtype == "success" {
|
let state = if subtype == "success" {
|
||||||
CharacterState::Success
|
CharacterState::Success
|
||||||
} else {
|
} else {
|
||||||
@@ -1078,12 +1085,18 @@ fn process_json_line(
|
|||||||
tools
|
tools
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Captured {} pending tool use(s): {:?}",
|
||||||
|
captured_pending_tools.len(),
|
||||||
|
captured_pending_tools.iter().map(|t| &t.tool_name).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
// Log turn metrics if available
|
// Log turn metrics if available
|
||||||
if let Some(duration) = duration_ms {
|
if let Some(duration) = duration_ms {
|
||||||
println!("Turn completed in {}ms", duration);
|
tracing::info!("Turn completed in {}ms", duration);
|
||||||
}
|
}
|
||||||
if let Some(turns) = num_turns {
|
if let Some(turns) = num_turns {
|
||||||
println!("Turn count: {}", turns);
|
tracing::info!("Turn count: {}", turns);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track token usage from Result messages if available
|
// Track token usage from Result messages if available
|
||||||
@@ -1113,7 +1126,7 @@ fn process_json_line(
|
|||||||
usage_info.cache_creation_input_tokens,
|
usage_info.cache_creation_input_tokens,
|
||||||
usage_info.cache_read_input_tokens,
|
usage_info.cache_read_input_tokens,
|
||||||
);
|
);
|
||||||
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
|
tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
|
||||||
usage_info.input_tokens,
|
usage_info.input_tokens,
|
||||||
usage_info.output_tokens,
|
usage_info.output_tokens,
|
||||||
usage_info.cache_creation_input_tokens,
|
usage_info.cache_creation_input_tokens,
|
||||||
@@ -1143,9 +1156,9 @@ fn process_json_line(
|
|||||||
let newly_unlocked = {
|
let newly_unlocked = {
|
||||||
let mut stats_guard = stats.write();
|
let mut stats_guard = stats.write();
|
||||||
stats_guard.get_session_duration();
|
stats_guard.get_session_duration();
|
||||||
println!("Checking achievements after result message...");
|
tracing::info!("Checking achievements after result message...");
|
||||||
let unlocked = stats_guard.check_achievements();
|
let unlocked = stats_guard.check_achievements();
|
||||||
println!("Newly unlocked achievements: {:?}", unlocked);
|
tracing::info!("Newly unlocked achievements: {:?}", unlocked);
|
||||||
unlocked
|
unlocked
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1160,20 +1173,20 @@ fn process_json_line(
|
|||||||
|
|
||||||
// Save achievements after unlocking new ones
|
// Save achievements after unlocking new ones
|
||||||
if !newly_unlocked.is_empty() {
|
if !newly_unlocked.is_empty() {
|
||||||
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
|
tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked);
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let achievements_progress = stats.read().achievements.clone();
|
let achievements_progress = stats.read().achievements.clone();
|
||||||
|
|
||||||
// Use Tauri's async runtime instead of tokio::spawn
|
// Use Tauri's async runtime instead of tokio::spawn
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("Spawned save task for achievements");
|
tracing::info!("Spawned save task for achievements");
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("Failed to save achievements: {}", e);
|
tracing::error!("Failed to save achievements: {}", e);
|
||||||
} else {
|
} else {
|
||||||
println!("Achievement save task completed successfully");
|
tracing::info!("Achievement save task completed successfully");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1190,9 +1203,9 @@ fn process_json_line(
|
|||||||
{
|
{
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("Periodic stats save (every 10 messages)...");
|
tracing::info!("Periodic stats save (every 10 messages)...");
|
||||||
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||||
eprintln!("Failed to save stats: {}", e);
|
tracing::error!("Failed to save stats: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1218,12 +1231,6 @@ fn process_json_line(
|
|||||||
if let Some(denials) = permission_denials {
|
if let Some(denials) = permission_denials {
|
||||||
// Only process if there are actually denials
|
// Only process if there are actually denials
|
||||||
if !denials.is_empty() {
|
if !denials.is_empty() {
|
||||||
// Skip permission prompts if the result was successful - tools were already approved
|
|
||||||
if subtype == "success" {
|
|
||||||
emit_state_change(app, state, None, conversation_id.clone());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut regular_permission_requests = Vec::new();
|
let mut regular_permission_requests = Vec::new();
|
||||||
|
|
||||||
// Get denied tool IDs for later comparison
|
// Get denied tool IDs for later comparison
|
||||||
@@ -1239,8 +1246,18 @@ fn process_json_line(
|
|||||||
for denial in denials {
|
for denial in denials {
|
||||||
// Skip system tools that should never require permission
|
// Skip system tools that should never require permission
|
||||||
if is_system_tool(&denial.tool_name) {
|
if is_system_tool(&denial.tool_name) {
|
||||||
|
tracing::debug!(
|
||||||
|
"Skipping system tool: {} (id: {})",
|
||||||
|
denial.tool_name,
|
||||||
|
denial.tool_use_id
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
"Processing permission denial for: {} (id: {})",
|
||||||
|
denial.tool_name,
|
||||||
|
denial.tool_use_id
|
||||||
|
);
|
||||||
|
|
||||||
// Special handling for AskUserQuestion tool
|
// Special handling for AskUserQuestion tool
|
||||||
if denial.tool_name == "AskUserQuestion" {
|
if denial.tool_name == "AskUserQuestion" {
|
||||||
@@ -1334,6 +1351,18 @@ fn process_json_line(
|
|||||||
|
|
||||||
// Emit all regular permission requests as a single batched event
|
// Emit all regular permission requests as a single batched event
|
||||||
if !regular_permission_requests.is_empty() {
|
if !regular_permission_requests.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
"Emitting permission event for {} tool(s) in conversation {:?}",
|
||||||
|
regular_permission_requests.len(),
|
||||||
|
conversation_id
|
||||||
|
);
|
||||||
|
for req in ®ular_permission_requests {
|
||||||
|
tracing::debug!(
|
||||||
|
"Permission requested: {} (id: {})",
|
||||||
|
req.tool_name,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:permission",
|
"claude:permission",
|
||||||
PermissionPromptEvent {
|
PermissionPromptEvent {
|
||||||
@@ -1411,7 +1440,7 @@ fn process_json_line(
|
|||||||
// Check achievements after user message
|
// Check achievements after user message
|
||||||
let newly_unlocked = {
|
let newly_unlocked = {
|
||||||
let mut stats_guard = stats.write();
|
let mut stats_guard = stats.write();
|
||||||
println!("User sent message, checking achievements...");
|
tracing::info!("User sent message, checking achievements...");
|
||||||
|
|
||||||
// Check message-based achievements
|
// Check message-based achievements
|
||||||
let mut unlocked = crate::achievements::check_message_achievements(
|
let mut unlocked = crate::achievements::check_message_achievements(
|
||||||
@@ -1428,7 +1457,7 @@ fn process_json_line(
|
|||||||
|
|
||||||
// Emit achievement events for any newly unlocked achievements
|
// Emit achievement events for any newly unlocked achievements
|
||||||
for achievement_id in &newly_unlocked {
|
for achievement_id in &newly_unlocked {
|
||||||
println!("User message unlocked achievement: {:?}", achievement_id);
|
tracing::info!("User message unlocked achievement: {:?}", achievement_id);
|
||||||
let info = get_achievement_info(achievement_id);
|
let info = get_achievement_info(achievement_id);
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"achievement:unlocked",
|
"achievement:unlocked",
|
||||||
@@ -1438,7 +1467,7 @@ fn process_json_line(
|
|||||||
|
|
||||||
// Save achievements after unlocking new ones
|
// Save achievements after unlocking new ones
|
||||||
if !newly_unlocked.is_empty() {
|
if !newly_unlocked.is_empty() {
|
||||||
println!("Saving newly unlocked achievements from user message");
|
tracing::info!("Saving newly unlocked achievements from user message");
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let achievements_progress = stats.read().achievements.clone();
|
let achievements_progress = stats.read().achievements.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -1446,9 +1475,9 @@ fn process_json_line(
|
|||||||
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
crate::achievements::save_achievements(&app_handle, &achievements_progress)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("Failed to save achievements: {}", e);
|
tracing::error!("Failed to save achievements: {}", e);
|
||||||
} else {
|
} else {
|
||||||
println!("Achievements saved after user message");
|
tracing::info!("Achievements saved after user message");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,15 +48,15 @@ $notifier.Show($toast)
|
|||||||
match output {
|
match output {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
println!("WSL notification sent successfully");
|
tracing::info!("WSL notification sent successfully");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||||
println!("PowerShell toast failed: {}", stderr);
|
tracing::error!("PowerShell toast failed: {}", stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to run PowerShell: {}", e);
|
tracing::error!("Failed to run PowerShell: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ $notifier.Show($toast)
|
|||||||
|
|
||||||
if let Ok(result) = notify_result {
|
if let Ok(result) = notify_result {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
println!("Notification sent via wsl-notify-send");
|
tracing::info!("Notification sent via wsl-notify-send");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ Please continue where we left off and retry those actions now that you have perm
|
|||||||
|
|
||||||
{#if permissions.length > 0}
|
{#if permissions.length > 0}
|
||||||
<div
|
<div
|
||||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
|
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
// Mock Tauri APIs
|
// Mock Tauri APIs
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
@@ -487,4 +488,329 @@ describe("config store", () => {
|
|||||||
expect(typeof configStore.saveError.subscribe).toBe("function");
|
expect(typeof configStore.saveError.subscribe).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Race Condition Tests", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup mock to return a default config for load_config
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
mockInvokeImpl.mockResolvedValue({
|
||||||
|
model: null,
|
||||||
|
api_key: null,
|
||||||
|
custom_instructions: null,
|
||||||
|
mcp_servers_json: null,
|
||||||
|
auto_granted_tools: [],
|
||||||
|
theme: "dark",
|
||||||
|
greeting_enabled: false,
|
||||||
|
greeting_custom_prompt: null,
|
||||||
|
notifications_enabled: false,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
always_on_top: false,
|
||||||
|
update_checks_enabled: false,
|
||||||
|
character_panel_width: null,
|
||||||
|
font_size: 14,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
|
custom_theme_colors: {
|
||||||
|
bg_primary: null,
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
},
|
||||||
|
budget_enabled: false,
|
||||||
|
session_token_budget: null,
|
||||||
|
session_cost_budget: null,
|
||||||
|
budget_action: "warn",
|
||||||
|
budget_warning_threshold: 0.8,
|
||||||
|
discord_rpc_enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load initial config
|
||||||
|
await configStore.loadConfig();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rapid sequential config updates correctly", async () => {
|
||||||
|
// This test validates the fix for the config race condition that caused data loss
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
|
||||||
|
|
||||||
|
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||||
|
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||||
|
invokeCalls.push({ command, config: args.config as HikariConfig });
|
||||||
|
// Simulate small delay in saving
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform rapid updates
|
||||||
|
await Promise.all([
|
||||||
|
configStore.updateConfig({ font_size: 16 }),
|
||||||
|
configStore.updateConfig({ theme: "light" }),
|
||||||
|
configStore.updateConfig({ compact_mode: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// All three updates should have been saved
|
||||||
|
expect(invokeCalls.length).toBe(3);
|
||||||
|
|
||||||
|
// Get final config
|
||||||
|
const finalConfig = configStore.getConfig();
|
||||||
|
|
||||||
|
// Final config should have all updates
|
||||||
|
// Note: The last update wins for each field, but all fields should be preserved
|
||||||
|
expect(finalConfig.compact_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves previous field values during concurrent updates", async () => {
|
||||||
|
// Set initial values
|
||||||
|
await configStore.updateConfig({
|
||||||
|
font_size: 16,
|
||||||
|
theme: "dark",
|
||||||
|
compact_mode: false,
|
||||||
|
streamer_mode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
const invokeCalls: Array<{ command: string; config: HikariConfig }> = [];
|
||||||
|
|
||||||
|
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||||
|
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||||
|
invokeCalls.push({ command, config: args.config as HikariConfig });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update different fields concurrently
|
||||||
|
await Promise.all([
|
||||||
|
configStore.updateConfig({ font_size: 18 }),
|
||||||
|
configStore.updateConfig({ theme: "light" }),
|
||||||
|
configStore.updateConfig({ compact_mode: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check that each save included all previous config values
|
||||||
|
invokeCalls.forEach((call) => {
|
||||||
|
// Each save should have a complete config, not just the updated field
|
||||||
|
expect(call.config).toHaveProperty("font_size");
|
||||||
|
expect(call.config).toHaveProperty("theme");
|
||||||
|
expect(call.config).toHaveProperty("compact_mode");
|
||||||
|
expect(call.config).toHaveProperty("streamer_mode");
|
||||||
|
expect(call.config).toHaveProperty("model");
|
||||||
|
expect(call.config).toHaveProperty("api_key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles update during save operation", async () => {
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
let firstSaveStarted = false;
|
||||||
|
let firstSaveCompleted = false;
|
||||||
|
|
||||||
|
mockInvokeImpl.mockImplementation(async (command: string) => {
|
||||||
|
if (command === "save_config") {
|
||||||
|
if (!firstSaveStarted) {
|
||||||
|
firstSaveStarted = true;
|
||||||
|
// Simulate slow save
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
firstSaveCompleted = true;
|
||||||
|
} else {
|
||||||
|
// Second save starts while first is in progress
|
||||||
|
expect(firstSaveStarted).toBe(true);
|
||||||
|
// First save might not be complete yet (race condition scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start first update
|
||||||
|
const firstUpdate = configStore.updateConfig({ font_size: 16 });
|
||||||
|
|
||||||
|
// Wait a bit then start second update whilst first is still saving
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
const secondUpdate = configStore.updateConfig({ theme: "light" });
|
||||||
|
|
||||||
|
// Wait for both to complete
|
||||||
|
await Promise.all([firstUpdate, secondUpdate]);
|
||||||
|
|
||||||
|
// Both should complete successfully without errors
|
||||||
|
expect(firstSaveCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getConfig returns most recently set configuration", async () => {
|
||||||
|
await configStore.updateConfig({ font_size: 14 });
|
||||||
|
expect(configStore.getConfig().font_size).toBe(14);
|
||||||
|
|
||||||
|
await configStore.updateConfig({ font_size: 16 });
|
||||||
|
expect(configStore.getConfig().font_size).toBe(16);
|
||||||
|
|
||||||
|
await configStore.updateConfig({ font_size: 18 });
|
||||||
|
expect(configStore.getConfig().font_size).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates do not lose data from previous operations", async () => {
|
||||||
|
// Set multiple fields
|
||||||
|
await configStore.updateConfig({
|
||||||
|
font_size: 16,
|
||||||
|
theme: "dark",
|
||||||
|
compact_mode: true,
|
||||||
|
streamer_mode: true,
|
||||||
|
model: "claude-sonnet-4",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update just one field
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
|
||||||
|
// Other fields should be preserved
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
expect(config.theme).toBe("light");
|
||||||
|
expect(config.font_size).toBe(16);
|
||||||
|
expect(config.compact_mode).toBe(true);
|
||||||
|
expect(config.streamer_mode).toBe(true);
|
||||||
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto granted tools are not lost during other updates", async () => {
|
||||||
|
// Add some tools
|
||||||
|
await configStore.addAutoGrantedTool("Read");
|
||||||
|
await configStore.addAutoGrantedTool("Write");
|
||||||
|
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
|
||||||
|
|
||||||
|
// Update another field
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
|
||||||
|
// Tools should still be there
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toContain("Read");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toContain("Write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom theme colors persist across other config updates", async () => {
|
||||||
|
const customColors: CustomThemeColors = {
|
||||||
|
bg_primary: "#1a1a2e",
|
||||||
|
bg_secondary: "#16213e",
|
||||||
|
bg_terminal: "#0f0f23",
|
||||||
|
accent_primary: "#e94560",
|
||||||
|
accent_secondary: "#533483",
|
||||||
|
text_primary: "#eaeaea",
|
||||||
|
text_secondary: "#a0a0a0",
|
||||||
|
border_color: "#333355",
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.setCustomThemeColors(customColors);
|
||||||
|
|
||||||
|
// Update another field
|
||||||
|
await configStore.updateConfig({ font_size: 18 });
|
||||||
|
|
||||||
|
// Colors should still be there
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e");
|
||||||
|
expect(config.custom_theme_colors.accent_primary).toBe("#e94560");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles save errors gracefully without losing data", async () => {
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|
||||||
|
// Set initial config
|
||||||
|
await configStore.updateConfig({ font_size: 14 });
|
||||||
|
|
||||||
|
// Make next save fail
|
||||||
|
mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed"));
|
||||||
|
|
||||||
|
// Try to update - should throw
|
||||||
|
await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow();
|
||||||
|
|
||||||
|
// Original config should still be accessible
|
||||||
|
expect(configStore.getConfig().font_size).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Config Persistence Tests", () => {
|
||||||
|
it("loadConfig retrieves saved configuration", async () => {
|
||||||
|
const mockConfig: HikariConfig = {
|
||||||
|
model: "claude-sonnet-4",
|
||||||
|
api_key: "test-key",
|
||||||
|
custom_instructions: "Be helpful",
|
||||||
|
mcp_servers_json: "{}",
|
||||||
|
auto_granted_tools: ["Read", "Write"],
|
||||||
|
theme: "light",
|
||||||
|
greeting_enabled: false,
|
||||||
|
greeting_custom_prompt: null,
|
||||||
|
notifications_enabled: false,
|
||||||
|
notification_volume: 0.5,
|
||||||
|
always_on_top: true,
|
||||||
|
update_checks_enabled: false,
|
||||||
|
character_panel_width: 400,
|
||||||
|
font_size: 18,
|
||||||
|
streamer_mode: true,
|
||||||
|
streamer_hide_paths: true,
|
||||||
|
compact_mode: true,
|
||||||
|
profile_name: "Test User",
|
||||||
|
profile_avatar_path: "/test/avatar.png",
|
||||||
|
profile_bio: "Test bio",
|
||||||
|
custom_theme_colors: {
|
||||||
|
bg_primary: null,
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
},
|
||||||
|
budget_enabled: true,
|
||||||
|
session_token_budget: 100000,
|
||||||
|
session_cost_budget: 1.5,
|
||||||
|
budget_action: "block",
|
||||||
|
budget_warning_threshold: 0.9,
|
||||||
|
discord_rpc_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
mockInvokeImpl.mockResolvedValueOnce(mockConfig);
|
||||||
|
|
||||||
|
await configStore.loadConfig();
|
||||||
|
|
||||||
|
const loadedConfig = configStore.getConfig();
|
||||||
|
expect(loadedConfig.model).toBe("claude-sonnet-4");
|
||||||
|
expect(loadedConfig.theme).toBe("light");
|
||||||
|
expect(loadedConfig.font_size).toBe(18);
|
||||||
|
expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saveConfig persists configuration to backend", async () => {
|
||||||
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
const savedConfigs: HikariConfig[] = [];
|
||||||
|
|
||||||
|
mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => {
|
||||||
|
if (command === "save_config" && args && typeof args === "object" && "config" in args) {
|
||||||
|
savedConfigs.push(args.config as HikariConfig);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const configToSave: Partial<HikariConfig> = {
|
||||||
|
model: "claude-sonnet-4",
|
||||||
|
theme: "dark",
|
||||||
|
font_size: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.updateConfig(configToSave);
|
||||||
|
|
||||||
|
expect(savedConfigs.length).toBeGreaterThan(0);
|
||||||
|
const lastSaved = savedConfigs[savedConfigs.length - 1];
|
||||||
|
expect(lastSaved.model).toBe("claude-sonnet-4");
|
||||||
|
expect(lastSaved.theme).toBe("dark");
|
||||||
|
expect(lastSaved.font_size).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-21
@@ -92,6 +92,14 @@ function createConfigStore() {
|
|||||||
const isSidebarOpen = writable<boolean>(false);
|
const isSidebarOpen = writable<boolean>(false);
|
||||||
const saveError = writable<string | null>(null);
|
const saveError = writable<string | null>(null);
|
||||||
|
|
||||||
|
// Internal function to get current config synchronously
|
||||||
|
function getCurrentConfig(): HikariConfig {
|
||||||
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
const unsubscribe = config.subscribe((c) => (currentConfig = c));
|
||||||
|
unsubscribe();
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
isLoading.set(true);
|
isLoading.set(true);
|
||||||
try {
|
try {
|
||||||
@@ -119,8 +127,7 @@ function createConfigStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateConfig(updates: Partial<HikariConfig>) {
|
async function updateConfig(updates: Partial<HikariConfig>) {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
const newConfig = { ...currentConfig, ...updates };
|
const newConfig = { ...currentConfig, ...updates };
|
||||||
await saveConfig(newConfig);
|
await saveConfig(newConfig);
|
||||||
}
|
}
|
||||||
@@ -145,15 +152,13 @@ function createConfigStore() {
|
|||||||
updates.custom_theme_colors = customColors;
|
updates.custom_theme_colors = customColors;
|
||||||
}
|
}
|
||||||
await updateConfig(updates);
|
await updateConfig(updates);
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
applyTheme(theme, currentConfig.custom_theme_colors);
|
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||||
await updateConfig({ custom_theme_colors: colors });
|
await updateConfig({ custom_theme_colors: colors });
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
if (currentConfig.theme === "custom") {
|
if (currentConfig.theme === "custom") {
|
||||||
applyCustomThemeColors(colors);
|
applyCustomThemeColors(colors);
|
||||||
}
|
}
|
||||||
@@ -166,16 +171,14 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
increaseFontSize: async () => {
|
increaseFontSize: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
|
||||||
await updateConfig({ font_size: newSize });
|
await updateConfig({ font_size: newSize });
|
||||||
applyFontSize(newSize);
|
applyFontSize(newSize);
|
||||||
},
|
},
|
||||||
|
|
||||||
decreaseFontSize: async () => {
|
decreaseFontSize: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
|
||||||
await updateConfig({ font_size: newSize });
|
await updateConfig({ font_size: newSize });
|
||||||
applyFontSize(newSize);
|
applyFontSize(newSize);
|
||||||
@@ -187,8 +190,7 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addAutoGrantedTool: async (tool: string) => {
|
addAutoGrantedTool: async (tool: string) => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
if (!currentConfig.auto_granted_tools.includes(tool)) {
|
if (!currentConfig.auto_granted_tools.includes(tool)) {
|
||||||
const newTools = [...currentConfig.auto_granted_tools, tool];
|
const newTools = [...currentConfig.auto_granted_tools, tool];
|
||||||
await updateConfig({ auto_granted_tools: newTools });
|
await updateConfig({ auto_granted_tools: newTools });
|
||||||
@@ -196,27 +198,22 @@ function createConfigStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeAutoGrantedTool: async (tool: string) => {
|
removeAutoGrantedTool: async (tool: string) => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
|
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
|
||||||
await updateConfig({ auto_granted_tools: newTools });
|
await updateConfig({ auto_granted_tools: newTools });
|
||||||
},
|
},
|
||||||
|
|
||||||
getConfig: (): HikariConfig => {
|
getConfig: (): HikariConfig => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
return getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
return currentConfig;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleStreamerMode: async () => {
|
toggleStreamerMode: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleCompactMode: async () => {
|
toggleCompactMode: async () => {
|
||||||
let currentConfig: HikariConfig = defaultConfig;
|
const currentConfig = getCurrentConfig();
|
||||||
config.subscribe((c) => (currentConfig = c))();
|
|
||||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ function createDebugConsoleStore() {
|
|||||||
originalConsole.debug(...args);
|
originalConsole.debug(...args);
|
||||||
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
|
addLog("debug", args.map((arg) => String(arg)).join(" "), "frontend");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture unhandled errors
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
addLog(
|
||||||
|
"error",
|
||||||
|
`[Unhandled Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
|
||||||
|
"frontend"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture unhandled promise rejections
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
addLog("error", `[Unhandled Promise Rejection] ${event.reason}`, "frontend");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreConsole() {
|
function restoreConsole() {
|
||||||
|
|||||||
@@ -285,6 +285,73 @@ describe("snippetsStore", () => {
|
|||||||
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filteredSnippets", () => {
|
||||||
|
it("returns all snippets when no category selected", async () => {
|
||||||
|
const mockSnippets: Snippet[] = [
|
||||||
|
{
|
||||||
|
id: "snippet-1",
|
||||||
|
name: "Git Status",
|
||||||
|
content: "git status",
|
||||||
|
category: "Git",
|
||||||
|
is_default: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "snippet-2",
|
||||||
|
name: "Docker PS",
|
||||||
|
content: "docker ps",
|
||||||
|
category: "Docker",
|
||||||
|
is_default: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setMockInvokeResult("list_snippets", mockSnippets);
|
||||||
|
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
||||||
|
|
||||||
|
await snippetsStore.loadSnippets();
|
||||||
|
snippetsStore.setSelectedCategory(null);
|
||||||
|
|
||||||
|
const filtered = get(snippetsStore.filteredSnippets);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters snippets by selected category", async () => {
|
||||||
|
const mockSnippets: Snippet[] = [
|
||||||
|
{
|
||||||
|
id: "snippet-1",
|
||||||
|
name: "Git Status",
|
||||||
|
content: "git status",
|
||||||
|
category: "Git",
|
||||||
|
is_default: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "snippet-2",
|
||||||
|
name: "Docker PS",
|
||||||
|
content: "docker ps",
|
||||||
|
category: "Docker",
|
||||||
|
is_default: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setMockInvokeResult("list_snippets", mockSnippets);
|
||||||
|
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
||||||
|
|
||||||
|
await snippetsStore.loadSnippets();
|
||||||
|
snippetsStore.setSelectedCategory("Git");
|
||||||
|
|
||||||
|
const filtered = get(snippetsStore.filteredSnippets);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].category).toBe("Git");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("snippet ID generation", () => {
|
describe("snippet ID generation", () => {
|
||||||
|
|||||||
@@ -328,9 +328,15 @@ export async function initializeTauriListeners() {
|
|||||||
});
|
});
|
||||||
unlisteners.push(cwdUnlisten);
|
unlisteners.push(cwdUnlisten);
|
||||||
|
|
||||||
|
console.log("[Tauri Listener] Setting up claude:permission listener");
|
||||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||||
const { permissions, conversation_id } = event.payload;
|
const { permissions, conversation_id } = event.payload;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`,
|
||||||
|
{ permissions, conversation_id }
|
||||||
|
);
|
||||||
|
|
||||||
// Store each permission request for the specific conversation
|
// Store each permission request for the specific conversation
|
||||||
for (const permission of permissions) {
|
for (const permission of permissions) {
|
||||||
const { id, tool_name, tool_input, description } = permission;
|
const { id, tool_name, tool_input, description } = permission;
|
||||||
|
|||||||
@@ -135,6 +135,151 @@ describe("stateMapper", () => {
|
|||||||
};
|
};
|
||||||
expect(mapMessageToState(message)).toBeNull();
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns typing for unknown tool", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool-1",
|
||||||
|
name: "SomeUnknownTool",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: "claude-3",
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("typing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns thinking for thinking content block", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
|
||||||
|
model: "claude-3",
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for assistant message with no recognizable content", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [],
|
||||||
|
model: "claude-3",
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns thinking for thinking_delta stream event", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: "thinking_delta",
|
||||||
|
thinking: "Thinking...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns typing for text_delta stream event", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: "text_delta",
|
||||||
|
text: "Hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("typing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns thinking for thinking content_block_start", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns typing for text content_block_start", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("typing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct state for tool_use content_block_start", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool-1",
|
||||||
|
name: "Read",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBe("searching");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for stream_event with unrecognized type", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "message_start",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for result with unknown subtype", () => {
|
||||||
|
const message = {
|
||||||
|
type: "result",
|
||||||
|
subtype: "unknown_type",
|
||||||
|
} as unknown as ClaudeStreamMessage;
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown message type", () => {
|
||||||
|
const message = {
|
||||||
|
type: "unknown_type",
|
||||||
|
} as unknown as ClaudeStreamMessage;
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractTextFromMessage", () => {
|
describe("extractTextFromMessage", () => {
|
||||||
@@ -192,6 +337,36 @@ describe("stateMapper", () => {
|
|||||||
};
|
};
|
||||||
expect(extractTextFromMessage(message)).toBe("Completed successfully");
|
expect(extractTextFromMessage(message)).toBe("Completed successfully");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null for result without result field", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
};
|
||||||
|
expect(extractTextFromMessage(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for stream_event without delta text", () => {
|
||||||
|
const message: ClaudeStreamMessage = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(extractTextFromMessage(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown message type", () => {
|
||||||
|
const message = {
|
||||||
|
type: "unknown",
|
||||||
|
} as unknown as ClaudeStreamMessage;
|
||||||
|
expect(extractTextFromMessage(message)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractToolInfo", () => {
|
describe("extractToolInfo", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user