From c670f40383e614c441cd7429de81042cdf79fc50 Mon Sep 17 00:00:00 2001 From: Ian Schwartz Date: Wed, 8 Apr 2026 23:28:13 -0500 Subject: [PATCH 1/2] Fix ProviderError and worktree add failures --- src/agent/channel.rs | 9 ++++++--- src/config/load.rs | 4 +++- src/llm/model.rs | 26 ++++++++++++++++++++------ src/projects/git.rs | 2 +- src/tools/project_manage.rs | 10 ++++++---- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 1539bda2e..27e7b3c5d 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -944,9 +944,11 @@ impl Channel { "/quiet" | "/observe" => { self.set_response_mode(ResponseMode::Observe).await; self.send_builtin_text( - "observe mode enabled. i'll learn from this conversation but won't respond.".to_string(), + "observe mode enabled. i'll learn from this conversation but won't respond." + .to_string(), "observe", - ).await; + ) + .await; return Ok(true); } "/active" => { @@ -976,7 +978,8 @@ impl Channel { "- /tasks: ready task list".to_string(), "- /digest: one-shot day digest (00:00 -> now)".to_string(), "- /observe: learn from conversation, never respond".to_string(), - "- /mention-only: only respond when @mentioned, replied to, or given a command".to_string(), + "- /mention-only: only respond when @mentioned, replied to, or given a command" + .to_string(), "- /active: normal reply mode".to_string(), "- /agent-id: runtime agent id".to_string(), ]; diff --git a/src/config/load.rs b/src/config/load.rs index 1e6997515..e7779f54d 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -137,7 +137,9 @@ fn parse_response_mode( // Backwards compat: listen_only_mode maps to response_mode match listen_only_mode { Some(true) => { - tracing::warn!("listen_only_mode is deprecated, use response_mode = \"observe\" instead"); + tracing::warn!( + "listen_only_mode is deprecated, use response_mode = \"observe\" instead" + ); Some(ResponseMode::Observe) } Some(false) => Some(ResponseMode::Active), diff --git a/src/llm/model.rs b/src/llm/model.rs index 55f1b2745..8d3c3ce3f 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -2348,26 +2348,40 @@ fn parse_streamed_tool_arguments( return Ok(serde_json::json!({})); } - let direct_parse_error = match serde_json::from_str::(raw_arguments) { - Ok(arguments) => return Ok(arguments), - Err(error) => error, + let mut iter = + serde_json::Deserializer::from_str(raw_arguments).into_iter::(); + let direct_parse_error = match iter.next() { + Some(Ok(arguments)) => return Ok(arguments), + Some(Err(error)) => error, + None => { + return Err(CompletionError::ProviderError(format!( + "invalid streamed tool arguments for '{tool_name}': empty JSON stream" + ))); + } }; let sanitized_arguments = escape_control_characters_in_json_strings(raw_arguments); if sanitized_arguments != raw_arguments { - match serde_json::from_str::(&sanitized_arguments) { - Ok(arguments) => { + let mut sanitized_iter = serde_json::Deserializer::from_str(&sanitized_arguments) + .into_iter::(); + match sanitized_iter.next() { + Some(Ok(arguments)) => { tracing::warn!( tool_name, "normalized control characters in streamed tool arguments" ); return Ok(arguments); } - Err(sanitized_parse_error) => { + Some(Err(sanitized_parse_error)) => { return Err(CompletionError::ProviderError(format!( "invalid streamed tool arguments for '{tool_name}': {direct_parse_error}; after sanitization: {sanitized_parse_error}" ))); } + None => { + return Err(CompletionError::ProviderError(format!( + "invalid streamed tool arguments for '{tool_name}': {direct_parse_error}; after sanitization: empty JSON stream" + ))); + } } } diff --git a/src/projects/git.rs b/src/projects/git.rs index 6e685d7c9..d3cea3dfc 100644 --- a/src/projects/git.rs +++ b/src/projects/git.rs @@ -257,7 +257,7 @@ pub async fn remove_worktree(repo_path: &Path, worktree_path: &Path) -> anyhow:: .context("worktree path is not valid UTF-8")?; let output = Command::new("git") - .args(["worktree", "remove", worktree_str]) + .args(["worktree", "remove", "--force", worktree_str]) .current_dir(repo_path) .output() .await diff --git a/src/tools/project_manage.rs b/src/tools/project_manage.rs index 22053c99c..305670e03 100644 --- a/src/tools/project_manage.rs +++ b/src/tools/project_manage.rs @@ -640,9 +640,8 @@ impl ProjectManageTool { let repo_abs_path = root.join(&repo.path); let is_single_repo = repo.path == "."; - // For single-repo projects, place worktrees in the parent directory - // (as siblings of the repo). For multi-repo projects, place them - // inside the project root. + // For multi-repo projects, place them inside the project root, + // prefixed with `.worktrees/repo_name-worktree_name` to avoid conflicts. let (worktree_abs_path, worktree_db_path) = if is_single_repo { let parent = root.parent().ok_or_else(|| { ProjectManageError("single-repo project root has no parent directory".into()) @@ -652,7 +651,10 @@ impl ProjectManageTool { format!("../{worktree_dir_name}"), ) } else { - (root.join(&worktree_dir_name), worktree_dir_name.clone()) + // Include repo name in the worktree directory name to avoid conflicts + // with other repos or their worktrees in a multi-repo project. + let dir_name = format!("{}-{}", repo.name, worktree_dir_name); + (root.join(&dir_name), dir_name) }; // Create the git worktree (branch from HEAD of the repo) From 53a7b527e931be6c850d4d6367ea318bee482df9 Mon Sep 17 00:00:00 2001 From: Ian Schwartz Date: Wed, 8 Apr 2026 23:48:02 -0500 Subject: [PATCH 2/2] chore: fix UTF-8 slicing panics and enterprise slack routing limits --- src/agent/channel.rs | 6 ++++-- src/llm/model.rs | 8 +++++--- src/messaging/signal.rs | 3 ++- src/messaging/target.rs | 8 ++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 27e7b3c5d..e88c28aa1 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -3007,7 +3007,8 @@ impl Channel { // Truncate for working memory — full conclusion lives in branch_runs. let summary = if conclusion.len() > 200 { - format!("{}...", &conclusion[..200]) + let end = conclusion.floor_char_boundary(200); + format!("{}...", &conclusion[..end]) } else { conclusion.clone() }; @@ -3080,7 +3081,8 @@ impl Channel { // Record worker completion in working memory. let worker_summary = if result.len() > 200 { - format!("{}...", &result[..200]) + let end = result.floor_char_boundary(200); + format!("{}...", &result[..end]) } else { result.clone() }; diff --git a/src/llm/model.rs b/src/llm/model.rs index 8d3c3ce3f..60feb42f1 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -3657,14 +3657,16 @@ fn provider_display_name(provider_id: &str) -> String { } fn remap_model_name_for_api(provider: &str, model_name: &str) -> String { + let stripped = model_name.strip_prefix("litellm/").unwrap_or(model_name); + if provider == "zai-coding-plan" { // Coding Plan endpoint expects plain model ids (e.g. "glm-5"). - model_name + stripped .strip_prefix("zai/") - .unwrap_or(model_name) + .unwrap_or(stripped) .to_string() } else { - model_name.to_string() + stripped.to_string() } } diff --git a/src/messaging/signal.rs b/src/messaging/signal.rs index ff4fe72b5..01a261caf 100644 --- a/src/messaging/signal.rs +++ b/src/messaging/signal.rs @@ -897,7 +897,8 @@ impl Messaging for SignalAdapter { // Log response body at debug level only (may contain sensitive data) if let Ok(body_text) = response.text().await { let truncated = if body_text.len() > 200 { - format!("{}...", &body_text[..200]) + let end = body_text.floor_char_boundary(200); + format!("{}...", &body_text[..end]) } else { body_text }; diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 3960fa398..bcc195520 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -666,10 +666,10 @@ pub fn is_valid_instance_name(name: &str) -> bool { if name.chars().all(|c| c.is_ascii_digit()) { return false; } - // Must not look like a Slack workspace ID (Txxxxx, Cxxxxx, etc.) - if name.len() > 6 - && name.starts_with(|c: char| c.is_ascii_uppercase()) - && name[1..].chars().all(|c| c.is_ascii_digit()) + // Must not look like a Slack workspace ID (Txxxxx, Cxxxxx, Exxxxx, etc.) + if name.len() >= 6 + && (name.starts_with('T') || name.starts_with('C') || name.starts_with('E')) + && name[1..].chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { return false; }