Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b17e6eb
feat: full LSP (Language Server Protocol) integration
TheArchitectit Apr 27, 2026
eeb49ec
fix: LSP discovery finds rust-analyzer via rustup proxy
TheArchitectit Apr 27, 2026
3bde030
feat: auto-LSP integration with didOpen/didChange and diagnostic enri…
TheArchitectit Apr 27, 2026
3b52e78
refactor: split LSP modules under 500 lines each
TheArchitectit Apr 27, 2026
4ea3a71
feat(lsp): add lspAutoStart config, remove unused LSP client/process/…
TheArchitectit Apr 27, 2026
6b75bdc
fix: resolve cherry-pick conflicts and remove non-LSP artifacts
TheArchitectit Apr 27, 2026
eafd569
feat(lsp): install prompts, new servers, and advanced LSP features
TheArchitectit Apr 28, 2026
b25b1f8
feat(lsp): add TCP transport for GDScript/Godot LSP (port 6008)
TheArchitectit Apr 28, 2026
0f2cb2c
fix(lsp): suppress Node.js deprecation warnings from JS-based LSP ser…
TheArchitectit Apr 28, 2026
c35cd0a
feat(lsp): show "Loading LSP servers..." with start/stop hints
TheArchitectit Apr 28, 2026
777e9b7
fix: make id field optional in OpenAI response parsing
TheArchitectit Apr 30, 2026
5d7cfe5
chore: add install script for rebuild and link
TheArchitectit Apr 30, 2026
56daf71
fix: detect HTML responses in streaming path
TheArchitectit Apr 30, 2026
42f72c0
fix: detect raw JSON errors in streaming path
TheArchitectit Apr 30, 2026
4130cff
fix: support reasoning_content and thinking fields in streaming
TheArchitectit Apr 30, 2026
9daf33d
fix: make delta field optional in ChunkChoice
TheArchitectit Apr 30, 2026
86af869
fix: bounds check in compact boundary loop
TheArchitectit Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
24 changes: 23 additions & 1 deletion rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "lsp",
aliases: &[],
summary: "Show or manage LSP server status",
argument_hint: Some("[start|stop|restart <language>]"),
resume_supported: true,
},
];

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -1179,6 +1186,10 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Lsp {
action: Option<String>,
target: Option<String>,
},
Unknown(String),
}

Expand Down Expand Up @@ -1277,6 +1288,7 @@ impl SlashCommand {
Self::Tag { .. } => "/tag",
Self::OutputStyle { .. } => "/output-style",
Self::AddDir { .. } => "/add-dir",
Self::Lsp { .. } => "/lsp",
Self::Sandbox => "/sandbox",
Self::Mcp { .. } => "/mcp",
Self::Export { .. } => "/export",
Expand Down Expand Up @@ -1472,10 +1484,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 All @@ -1488,6 +1505,10 @@ pub fn validate_slash_command_input(
"tag" => SlashCommand::Tag { label: remainder },
"output-style" => SlashCommand::OutputStyle { style: remainder },
"add-dir" => SlashCommand::AddDir { path: remainder },
"lsp" => SlashCommand::Lsp {
action: args.first().map(|s| (*s).to_string()),
target: args.get(1).map(|s| (*s).to_string()),
},
"history" => SlashCommand::History {
count: optional_single_arg(command, &args, "[count]")?,
},
Expand Down Expand Up @@ -4298,6 +4319,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Unknown(_) => None,
}
}
Expand Down Expand Up @@ -4893,7 +4915,7 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert_eq!(slash_command_specs().len(), 140);
assert!(resume_supported_slash_commands().len() >= 39);
}

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
149 changes: 148 additions & 1 deletion rust/crates/runtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,83 @@ pub struct RuntimePluginConfig {
max_output_tokens: Option<u32>,
}

/// Per-language LSP server configuration supplied by the user in settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LspServerConfig {
pub command: String,
pub args: Vec<String>,
pub enabled: bool,
}

/// Structured feature configuration consumed by runtime subsystems.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
lsp_auto_start: bool,
aliases: BTreeMap<String, String>,
permission_mode: Option<ResolvedPermissionMode>,
permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
provider: RuntimeProviderConfig,
lsp: BTreeMap<String, LspServerConfig>,
}

impl Default for RuntimeFeatureConfig {
fn default() -> Self {
Self {
hooks: RuntimeHookConfig::default(),
plugins: RuntimePluginConfig::default(),
mcp: McpConfigCollection::default(),
oauth: None,
model: None,
lsp_auto_start: true,
aliases: BTreeMap::new(),
permission_mode: None,
permission_rules: RuntimePermissionRuleConfig::default(),
sandbox: SandboxConfig::default(),
provider_fallbacks: ProviderFallbackConfig::default(),
trusted_roots: Vec::new(),
provider: RuntimeProviderConfig::default(),
lsp: BTreeMap::new(),
}
}
}

/// Stored provider configuration from the setup wizard.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeProviderConfig {
kind: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
model: Option<String>,
}

impl RuntimeProviderConfig {
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}

#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}

#[must_use]
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}

#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}

/// Ordered chain of fallback model identifiers used when the primary
Expand Down Expand Up @@ -315,6 +378,13 @@ impl ConfigLoader {
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
lsp: parse_optional_lsp_config(&merged_value)?,
lsp_auto_start: merged_value
.as_object()
.and_then(|o| o.get("lspAutoStart"))
.and_then(JsonValue::as_bool)
.unwrap_or(true),
};

Ok(RuntimeConfig {
Expand Down Expand Up @@ -414,6 +484,21 @@ impl RuntimeConfig {
pub fn trusted_roots(&self) -> &[String] {
&self.feature_config.trusted_roots
}

#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.feature_config.provider
}

#[must_use]
pub fn lsp(&self) -> &BTreeMap<String, LspServerConfig> {
&self.feature_config.lsp
}

#[must_use]
pub fn lsp_auto_start(&self) -> bool {
self.feature_config.lsp_auto_start
}
}

impl RuntimeFeatureConfig {
Expand Down Expand Up @@ -483,6 +568,21 @@ impl RuntimeFeatureConfig {
pub fn trusted_roots(&self) -> &[String] {
&self.trusted_roots
}

#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.provider
}

#[must_use]
pub fn lsp(&self) -> &BTreeMap<String, LspServerConfig> {
&self.lsp
}

#[must_use]
pub fn lsp_auto_start(&self) -> bool {
self.lsp_auto_start
}
}

impl ProviderFallbackConfig {
Expand Down Expand Up @@ -950,6 +1050,53 @@ fn parse_optional_oauth_config(
}))
}

fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
return Ok(RuntimeProviderConfig::default());
};
let Some(object) = provider_value.as_object() else {
return Ok(RuntimeProviderConfig::default());
};
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
let model = optional_string(object, "model", "provider")?.map(str::to_string);
Ok(RuntimeProviderConfig {
kind,
api_key,
base_url,
model,
})
}

fn parse_optional_lsp_config(
root: &JsonValue,
) -> Result<BTreeMap<String, LspServerConfig>, ConfigError> {
let Some(lsp_value) = root.as_object().and_then(|object| object.get("lsp")) else {
return Ok(BTreeMap::new());
};
let lsp_object = expect_object(lsp_value, "merged settings.lsp")?;
let mut result = BTreeMap::new();
for (language, value) in lsp_object {
let entry = expect_object(value, &format!("merged settings.lsp.{language}"))?;
let command = expect_string(entry, "command", &format!("merged settings.lsp.{language}"))?
.to_string();
let args = optional_string_array(entry, "args", &format!("merged settings.lsp.{language}"))?
.unwrap_or_default();
let enabled = optional_bool(entry, "enabled", &format!("merged settings.lsp.{language}"))?
.unwrap_or(true);
result.insert(
language.clone(),
LspServerConfig {
command,
args,
enabled,
},
);
}
Ok(result)
}

fn parse_mcp_server_config(
server_name: &str,
value: &JsonValue,
Expand Down
Loading