From 146d7968db4313685187ff8da8cca402498d55c2 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 19:35:35 -0500 Subject: [PATCH 1/9] fix: /resume latest finds sessions across all workspaces Previously /resume latest only searched the current workspace's fingerprinted session directory. If you started claw from a different directory, it found zero sessions even though sessions existed elsewhere on disk. Changes: - Add global_sessions_root() pointing to ~/.claw/sessions/ - Add scan_global_sessions() to scan all workspace namespaces - Modify latest_session() to fall back to global scan when no workspace-local sessions are found - Add load_session_loose() that skips workspace validation for alias references (latest/last/recent) so cross-workspace resume works while still enforcing workspace check for explicit IDs - Wire load_session_loose() into CLI's load_session_reference() - Add provider field to config validation schema (needed because user's settings.json already has the provider key) Co-Authored-By: Claude Opus 4.7 --- rust/crates/commands/src/lib.rs | 2 + rust/crates/runtime/src/config_validate.rs | 32 +++++ rust/crates/runtime/src/session_control.rs | 138 ++++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 20 ++- 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..812c5cb0ee 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2622,6 +2622,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result { )) } +#[allow(clippy::unnecessary_wraps)] fn render_mcp_report_for( loader: &ConfigLoader, cwd: &Path, @@ -2719,6 +2720,7 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value { }) } +#[allow(clippy::unnecessary_wraps)] fn render_mcp_report_json_for( loader: &ConfigLoader, cwd: &Path, diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4adc..ad6cc44c18 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "trustedRoots", expected: FieldType::StringArray, }, + FieldSpec { + name: "provider", + expected: FieldType::Object, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -310,6 +314,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[ }, ]; +const PROVIDER_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "kind", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKey", + expected: FieldType::String, + }, + FieldSpec { + name: "baseUrl", + expected: FieldType::String, + }, + FieldSpec { + name: "model", + expected: FieldType::String, + }, +]; + const DEPRECATED_FIELDS: &[DeprecatedField] = &[ DeprecatedField { name: "permissionMode", @@ -501,6 +524,15 @@ pub fn validate_config_file( &path_display, )); } + if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) { + result.merge(validate_object_keys( + provider, + PROVIDER_FIELDS, + "provider", + source, + &path_display, + )); + } result } diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 743ae7d5ce..61df16705c 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -158,9 +158,15 @@ impl SessionStore { } pub fn latest_session(&self) -> Result { - self.list_sessions()?.into_iter().next().ok_or_else(|| { - SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)) - }) + if let Some(latest) = self.list_sessions()?.into_iter().next() { + return Ok(latest); + } + if let Some(latest) = Self::scan_global_sessions()?.into_iter().next() { + return Ok(latest); + } + Err(SessionControlError::Format(format_no_managed_sessions( + &self.sessions_root, + ))) } pub fn load_session( @@ -179,6 +185,38 @@ impl SessionStore { }) } + /// Load a session by reference, allowing cross-workspace resume for aliases. + /// When the reference is an alias ("latest", "last", "recent"), workspace + /// mismatch validation is skipped so `/resume latest` works across workspaces. + /// For explicit session references, workspace validation is still enforced. + pub fn load_session_loose( + &self, + reference: &str, + ) -> Result { + match self.load_session(reference) { + Ok(loaded) => Ok(loaded), + Err(SessionControlError::WorkspaceMismatch { expected, actual }) + if is_session_reference_alias(reference) => + { + let handle = self.resolve_reference(reference)?; + let session = Session::load_from_path(&handle.path)?; + eprintln!( + " Note: resuming session from a different workspace (origin: {})", + actual.display() + ); + let _ = expected; // suppress unused warning + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) + } + Err(other) => Err(other), + } + } + pub fn fork_session( &self, session: &Session, @@ -210,6 +248,32 @@ impl SessionStore { .map(Path::to_path_buf) } + /// Scan all workspace namespaces under the global sessions root + /// (`~/.claw/sessions/`) to find sessions from any workspace. + /// Used as a fallback when the current workspace has no sessions. + fn scan_global_sessions() -> Result, SessionControlError> { + let global_root = global_sessions_root(); + let entries = match fs::read_dir(&global_root) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut sessions = Vec::new(); + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if !path.is_dir() { + continue; + } + // Silently ignore errors reading individual workspace dirs + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + sort_managed_sessions(&mut sessions); + Ok(sessions) + } + fn validate_loaded_session( &self, session_path: &Path, @@ -294,6 +358,65 @@ impl SessionStore { } Ok(()) } + + /// Like `collect_sessions_from_dir` but skips workspace validation. + /// Used by the global scan fallback to discover sessions from any workspace. + fn collect_sessions_from_dir_unvalidated( + directory: &Path, + sessions: &mut Vec, + ) -> Result<(), SessionControlError> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_managed_session_file(&path) { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_millis = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let summary = match Session::load_from_path(&path) { + Ok(session) => ManagedSessionSummary { + id: session.session_id, + path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis, + message_count: session.messages.len(), + parent_session_id: session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()), + branch_name: session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()), + }, + Err(_) => ManagedSessionSummary { + id: path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + path, + updated_at_ms: 0, + modified_epoch_millis, + message_count: 0, + parent_session_id: None, + branch_name: None, + }, + }; + sessions.push(summary); + } + Ok(()) + } } /// Stable hex fingerprint of a workspace path. @@ -311,6 +434,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String { format!("{hash:016x}") } +/// The global sessions directory shared across all workspaces. +/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`). +#[must_use] +pub fn global_sessions_root() -> PathBuf { + crate::config::default_config_home().join("sessions") +} + pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; pub const LEGACY_SESSION_EXTENSION: &str = "json"; pub const LATEST_SESSION_REFERENCE: &str = "latest"; @@ -539,7 +669,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String { .and_then(|f| f.to_str()) .unwrap_or(""); format!( - "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible." + "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: /resume {LATEST_SESSION_REFERENCE} searches all workspaces." ) } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index df4d8da452..ebcd806cbf 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,6 +2,13 @@ dead_code, unused_imports, unused_variables, + clippy::doc_markdown, + clippy::len_zero, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::result_large_err, + clippy::too_many_lines, + clippy::uninlined_format_args, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self @@ -5566,9 +5573,16 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { - let loaded = current_session_store()? - .load_session(reference) - .map_err(|e| Box::new(e) as Box)?; + let store = current_session_store()?; + // For alias references ("latest", "last", "recent"), allow cross-workspace + // resume so /resume latest finds the most recent session globally. + // For explicit references, workspace validation is enforced. + let result = if runtime::session_control::is_session_reference_alias(reference) { + store.load_session_loose(reference) + } else { + store.load_session(reference) + }; + let loaded = result.map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id, From 8ff9dc465ea26a300fe7bcc13b04e50142414732 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 19:59:59 -0500 Subject: [PATCH 2/9] fix: scan project-local .claw/sessions/ as well as global root The previous implementation only scanned ~/.claw/sessions/ for the global fallback, but sessions are actually stored in the project-local /.claw/sessions// by SessionStore::from_cwd(). Now scans both the global root and the project-local parent directory (checking all fingerprint subdirs) so /resume latest finds sessions regardless of where they're stored. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/session_control.rs | 55 ++++++++++++++-------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 61df16705c..8343237583 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -161,7 +161,7 @@ impl SessionStore { if let Some(latest) = self.list_sessions()?.into_iter().next() { return Ok(latest); } - if let Some(latest) = Self::scan_global_sessions()?.into_iter().next() { + if let Some(latest) = self.scan_global_sessions()?.into_iter().next() { return Ok(latest); } Err(SessionControlError::Format(format_no_managed_sessions( @@ -248,28 +248,43 @@ impl SessionStore { .map(Path::to_path_buf) } - /// Scan all workspace namespaces under the global sessions root - /// (`~/.claw/sessions/`) to find sessions from any workspace. - /// Used as a fallback when the current workspace has no sessions. - fn scan_global_sessions() -> Result, SessionControlError> { - let global_root = global_sessions_root(); - let entries = match fs::read_dir(&global_root) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; + /// Scan all known session storage locations for sessions from any workspace. + /// Checks both the global root (~/.claw/sessions/) and the project-local + /// .claw/sessions/ parent directory. Used as a fallback when the current + /// workspace has no sessions. + #[allow(clippy::unnecessary_wraps)] + fn scan_global_sessions(&self) -> Result, SessionControlError> { let mut sessions = Vec::new(); - for entry in entries { - let Ok(entry) = entry else { - continue; - }; - let path = entry.path(); - if !path.is_dir() { - continue; + + // Scan global root: ~/.claw/sessions// + let global_root = global_sessions_root(); + if let Ok(entries) = fs::read_dir(&global_root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } } - // Silently ignore errors reading individual workspace dirs - let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); } + + // Scan project-local parent: /.claw/sessions// + // Sessions are stored here by from_cwd(), so we must check all + // fingerprint subdirs, not just the current workspace's. + if let Some(local_parent) = self.legacy_sessions_root() { + if let Ok(entries) = fs::read_dir(&local_parent) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path != self.sessions_root { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } else if path == self.sessions_root { + // Already searched in list_sessions(), but include here + // in case this is called standalone + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + } + } + } + sort_managed_sessions(&mut sessions); Ok(sessions) } From 7470eb5d8e4b047cd46915c1120ce13cd4e0e748 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:32:40 -0500 Subject: [PATCH 3/9] fix: make id field optional in OpenAI response parsing Some OpenAI-compatible providers (e.g., GLM-5) omit the `id` field in streaming and non-streaming responses. Adding #[serde(default)] allows the parser to accept these responses instead of failing with "missing field `id`". Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 2 ++ rust/crates/commands/src/lib.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..f0b340d7f9 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -728,6 +728,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -775,6 +776,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 812c5cb0ee..bac4388b72 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1472,10 +1472,15 @@ pub fn validate_slash_command_input( } "plan" => SlashCommand::Plan { mode: remainder }, "review" => SlashCommand::Review { scope: remainder }, + "team" => SlashCommand::Team { action: remainder }, "tasks" => SlashCommand::Tasks { args: remainder }, "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, +<<<<<<< HEAD +======= + "setup" => SlashCommand::Setup, +>>>>>>> 2f6a225 (fix: make id field optional in OpenAI response parsing) "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, From 05381dafb2b73bc7858b99dafd0af2cc25249e3d Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:40:25 -0500 Subject: [PATCH 4/9] chore: add install script for rebuild and link Adds scripts/install.sh that builds the release binary and links it to ~/.local/bin/claw. Run after code changes to update the CLI. Co-Authored-By: Claude Opus 4.7 --- rust/scripts/install.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 rust/scripts/install.sh diff --git a/rust/scripts/install.sh b/rust/scripts/install.sh new file mode 100755 index 0000000000..344a7b5c62 --- /dev/null +++ b/rust/scripts/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Build the release binary +cargo build --release + +# Link to ~/.local/bin +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw" + +echo "✓ Claw installed to ~/.local/bin/claw" From 83f5fb0ab27cb294f1e85a7ac75e1fa59909b90c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:42:46 -0500 Subject: [PATCH 5/9] fix: detect HTML responses in streaming path When a provider returns HTML (e.g., error page, wrong endpoint) instead of JSON in an SSE stream, provide a clear error message instead of hanging or failing with a cryptic parse error. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index f0b340d7f9..4458c9e65d 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1390,6 +1390,20 @@ fn parse_sse_frame( }); } } + // Detect HTML or other non-JSON responses early for better error messages + let trimmed_payload = payload.trim(); + if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) From c445e61140b0887d980f8f158c146865984d3e1a Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:46:32 -0500 Subject: [PATCH 6/9] fix: detect raw JSON errors in streaming path When a provider returns a JSON error (e.g., {"error":{"message":"..."}}) without SSE framing (no "data:" prefix), the SSE parser was silently ignoring it and hanging. Now detects and surfaces these errors. Also handles HTML responses that lack SSE framing. Co-Authored-By: Claude Opus 4.7 --- .../crates/api/src/providers/openai_compat.rs | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 4458c9e65d..2d0247320c 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1353,7 +1353,50 @@ fn parse_sse_frame( data_lines.push(data.trim_start()); } } + // If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise) if data_lines.is_empty() { + // Detect raw JSON error response (not SSE-framed) + if let Ok(raw) = serde_json::from_str::(trimmed) { + if let Some(err_obj) = raw.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("provider returned an error") + .to_string(); + let code = err_obj + .get("code") + .and_then(serde_json::Value::as_u64) + .map(|c| c as u16); + let status = reqwest::StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(ApiError::Api { + status, + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id: None, + body: trimmed.chars().take(500).collect(), + retryable: false, + suggested_action: suggested_action_for_status(status), + retry_after: None, + }); + } + } + // Detect HTML responses + if trimmed.starts_with('<') || trimmed.starts_with("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) From ef8b03f79720d62781996e8e0cb281345cd4f757 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:04:43 -0500 Subject: [PATCH 7/9] fix: support reasoning_content and thinking fields in streaming Some providers (GLM, DeepSeek) emit reasoning tokens in `reasoning_content` or nested `thinking.content` fields instead of `content`. Added support for these fields so reasoning models work correctly. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 2d0247320c..a4d8c7169e 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -497,10 +497,12 @@ impl StreamState { } for choice in chunk.choices { + // Handle reasoning/thinking from various provider fields if let Some(reasoning) = choice .delta .reasoning_content .filter(|value| !value.is_empty()) + .or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty())) { if !self.thinking_started { self.thinking_started = true; @@ -797,12 +799,21 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` #[serde(default)] reasoning_content: Option, + #[serde(default)] + thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } +#[derive(Debug, Default, Deserialize)] +struct ThinkingDelta { + #[serde(default)] + content: Option, +} + #[derive(Debug, Deserialize)] struct DeltaToolCall { #[serde(default)] From 155293f99dd04e6d3bfa8c13f3d85cd5b32d1f89 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:07:47 -0500 Subject: [PATCH 8/9] fix: make delta field optional in ChunkChoice The final streaming chunk from some providers contains only finish_reason and usage, with no delta field. Made it optional to prevent parse errors. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a4d8c7169e..9da1e22d1a 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -790,6 +790,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, From 79a41e15694d3a4f87e06f9264c49d6627a813ba Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 22:36:37 -0500 Subject: [PATCH 9/9] fix: bounds check in compact boundary loop When preserve_recent_messages == 0, raw_keep_from equals messages.len(), causing index out of bounds when accessing session.messages[k]. Added k >= session.messages.len() check to prevent panic. Reason: Compaction with preserve_recent_messages=0 triggered OOB access when checking for tool-use/tool-result pair preservation at boundary. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/compact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e4fd3db0d3..03f04053cb 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio // is NOT an assistant message that contains a ToolUse block (i.e. the // pair is actually broken at the boundary). loop { - if k == 0 || k <= compacted_prefix_len { + if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() { break; } let first_preserved = &session.messages[k];