Skip to content
57 changes: 57 additions & 0 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -728,6 +730,7 @@ impl ToolCallState {

#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[serde(default)]
id: String,
model: String,
choices: Vec<ChatChoice>,
Expand Down Expand Up @@ -775,6 +778,7 @@ struct OpenAiUsage {

#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: Option<String>,
Expand All @@ -786,6 +790,7 @@ struct ChatCompletionChunk {

#[derive(Debug, Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta,
#[serde(default)]
finish_reason: Option<String>,
Expand All @@ -795,12 +800,21 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}

#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
Expand Down Expand Up @@ -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::<serde_json::Value>(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("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
}
let payload = data_lines.join("\n");
Expand Down
7 changes: 7 additions & 0 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -2622,6 +2627,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
))
}

#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_for(
loader: &ConfigLoader,
cwd: &Path,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/runtime/src/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
32 changes: 32 additions & 0 deletions rust/crates/runtime/src/config_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "trustedRoots",
expected: FieldType::StringArray,
},
FieldSpec {
name: "provider",
expected: FieldType::Object,
},
];

const HOOKS_FIELDS: &[FieldSpec] = &[
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Expand Down
153 changes: 149 additions & 4 deletions rust/crates/runtime/src/session_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,15 @@ impl SessionStore {
}

pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
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(
Expand All @@ -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<LoadedManagedSession, SessionControlError> {
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,
Expand Down Expand Up @@ -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<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();

// Scan global root: ~/.claw/sessions/<fingerprint>/
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: <cwd>/.claw/sessions/<fingerprint>/
// 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,
Expand Down Expand Up @@ -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<ManagedSessionSummary>,
) -> 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.
Expand All @@ -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";
Expand Down Expand Up @@ -539,7 +684,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
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."
)
}

Expand Down
Loading