diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..9da1e22d1a 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; @@ -728,6 +730,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -775,6 +778,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, @@ -786,6 +790,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, @@ -795,12 +800,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)] @@ -1351,7 +1365,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(" 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 }, @@ -2622,6 +2627,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 +2725,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/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]; 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..8343237583 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,47 @@ impl SessionStore { .map(Path::to_path_buf) } + /// 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(); + + // 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); + } + } + } + + // 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) + } + fn validate_loaded_session( &self, session_path: &Path, @@ -294,6 +373,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 +449,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 +684,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, 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"