From 2b661d31f7b58fb270bede311b7a1a71194eebb1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 11:31:09 -0500 Subject: [PATCH 1/8] feat: add git-aware context tools (GitStatus, GitDiff, GitLog, GitShow, GitBlame) Add five native git tools that provide structured read-only access to repository state, replacing ad-hoc bash git commands with purpose-built tool definitions the model can discover and invoke directly. - GitStatus: working tree status with --short --branch - GitDiff: diff with optional staged/commit/path filters - GitLog: commit log with count, oneline, author, since/until, path - GitShow: show commit/tree content with optional stat and path - GitBlame: line-level blame with optional start/end line range All tools use the existing git_stdout() helper and are PermissionMode::ReadOnly. --- rust/crates/tools/GIT_TOOLS_README.md | 157 +++++++++++++++ rust/crates/tools/src/lib.rs | 275 ++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 rust/crates/tools/GIT_TOOLS_README.md diff --git a/rust/crates/tools/GIT_TOOLS_README.md b/rust/crates/tools/GIT_TOOLS_README.md new file mode 100644 index 0000000000..c937daab00 --- /dev/null +++ b/rust/crates/tools/GIT_TOOLS_README.md @@ -0,0 +1,157 @@ +# Git-Aware Context Tools + +Adds five native git tools to claw-code that provide structured, read-only access to repository state. These replace ad-hoc `git` commands via bash with purpose-built tool definitions the model can discover and invoke directly. + +## Tools + +### GitStatus + +Show the working tree status (branch, staged, unstaged, untracked). Equivalent to `git status --short --branch`. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `short` | boolean | no | `true` | Use `--short --branch` format for concise output | + +**Example input:** +```json +{} +``` + +**Example output:** +```json +{ + "output": "## feat/git-aware-tools...upstream/main [ahead 1]\nM rust/crates/tools/src/lib.rs" +} +``` + +--- + +### GitDiff + +Show changes between commits, the index, and the working tree. Supports staged changes, specific paths, commit ranges, and comparing two commits. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `staged` | boolean | no | `false` | Show staged changes (`git diff --cached`) | +| `commit` | string | no | — | Commit hash, tag, or branch to diff against | +| `commit2` | string | no | — | Second commit for range diff (`commit...commit2`) | +| `path` | string | no | — | File path to restrict the diff to | + +**Example inputs:** +```json +{} +``` +```json +{ "staged": true } +``` +```json +{ "commit": "HEAD~3", "path": "rust/crates/tools/src/lib.rs" } +``` +```json +{ "commit": "main", "commit2": "feat/git-aware-tools" } +``` + +--- + +### GitLog + +Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `count` | integer | no | `20` | Maximum number of commits to return | +| `oneline` | boolean | no | `false` | Use `--oneline` format (hash + subject only) | +| `author` | string | no | — | Filter commits by author pattern | +| `since` | string | no | — | Filter commits since date (e.g. `"2024-01-01"` or `"2.weeks"`) | +| `until` | string | no | — | Filter commits until date | +| `path` | string | no | — | File or directory path to filter commits by | + +**Example inputs:** +```json +{ "count": 5, "oneline": true } +``` +```json +{ "author": "alice", "since": "1.week", "path": "src/main.rs" } +``` + +--- + +### GitShow + +Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit and stat-only mode. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit` | string | **yes** | — | Commit hash, tag, or branch ref to show | +| `path` | string | no | — | Show only this file at the given commit (`commit:path` syntax) | +| `stat` | boolean | no | `false` | Show diffstat summary instead of full diff | + +**Example inputs:** +```json +{ "commit": "HEAD" } +``` +```json +{ "commit": "abc1234", "stat": true } +``` +```json +{ "commit": "main", "path": "src/lib.rs" } +``` + +--- + +### GitBlame + +Show what revision and author last modified each line of a file. Supports line range filtering. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `path` | string | **yes** | — | File path to blame | +| `start_line` | integer | no | — | Start of line range (1-based) | +| `end_line` | integer | no | — | End of line range (1-based) | + +**Example inputs:** +```json +{ "path": "src/main.rs" } +``` +```json +{ "path": "src/main.rs", "start_line": 100, "end_line": 150 } +``` + +--- + +## Architecture + +All five tools follow the same pattern: + +1. **ToolSpec** — Defines the tool name, description, JSON input schema, and `PermissionMode::ReadOnly` +2. **Input struct** — Derives `Deserialize` with `#[serde(default)]` on optional fields +3. **Run function** — Builds git arguments, calls `git_stdout()`, wraps result in JSON via `to_pretty_json()` +4. **Dispatch** — Matched in `execute_tool_with_enforcer()` like all other tools + +The existing `git_stdout(args: &[&str]) -> Option` helper (at `tools/src/lib.rs`) handles running the `git` subprocess and returning trimmed stdout. Git tools simply construct the right arguments and delegate to this helper. + +## Why native git tools? + +Before this PR, the model had to use the `bash` tool for git operations, which has several drawbacks: + +- **No structured output** — Bash returns raw text that the model must parse +- **Over-permissioned** — Bash requires `DangerFullAccess` even for read-only git commands +- **No discoverability** — The model can't search for git-capable tools via `ToolSearch` +- **Inconsistent** — Each invocation may use different flags or formatting + +With native git tools: + +- All five are `ReadOnly` — safe in restricted permission modes +- Structured JSON output — consistent, parseable results +- Discoverable via `ToolSearch` with keywords like "git", "diff", "blame" +- Model-friendly descriptions explain when to use each tool vs bash + +## Testing + +```bash +cd rust +cargo build --release +cargo test -p tools +``` + +The 3 pre-existing test failures (agent_fake_runner, agent_persists_handoff, worker_create_merges_config) are unrelated to this change — they fail due to local settings.json incompatibilities. diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index f3d1849ac1..4b0bbe2291 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1175,6 +1175,80 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::DangerFullAccess, }, + ToolSpec { + name: "GitStatus", + description: "Show the working tree status (branch, staged, unstaged, untracked). Equivalent to 'git status --short --branch'. Use this instead of running git status via bash to get structured, parseable output.", + input_schema: json!({ + "type": "object", + "properties": { + "short": { "type": "boolean" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitDiff", + description: "Show changes between commits, the index, and the working tree. Supports staged changes ('git diff --cached'), specific paths, commit ranges, and comparing two commits. Use this instead of running git diff via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "staged": { "type": "boolean" }, + "commit": { "type": "string" }, + "commit2": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitLog", + description: "Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. Defaults to the last 20 commits. Use this instead of running git log via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "count": { "type": "integer", "minimum": 1 }, + "oneline": { "type": "boolean" }, + "author": { "type": "string" }, + "since": { "type": "string" }, + "until": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitShow", + description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "commit": { "type": "string" }, + "path": { "type": "string" }, + "stat": { "type": "boolean" } + }, + "required": ["commit"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitBlame", + description: "Show what revision and author last modified each line of a file. Supports line range filtering (start_line, end_line). Use this instead of running git blame via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "start_line": { "type": "integer", "minimum": 1 }, + "end_line": { "type": "integer", "minimum": 1 } + }, + "required": ["path"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ] } @@ -1293,6 +1367,11 @@ fn execute_tool_with_enforcer( "TestingPermission" => { from_value::(input).and_then(run_testing_permission) } + "GitStatus" => from_value::(input).and_then(run_git_status), + "GitDiff" => from_value::(input).and_then(run_git_diff), + "GitLog" => from_value::(input).and_then(run_git_log), + "GitShow" => from_value::(input).and_then(run_git_show), + "GitBlame" => from_value::(input).and_then(run_git_blame), _ => Err(format!("unsupported tool: {name}")), } } @@ -1841,6 +1920,123 @@ fn run_testing_permission(input: TestingPermissionInput) -> Result Result { + let mut args: Vec<&str> = vec!["status"]; + if input.short.unwrap_or(true) { + args.push("--short"); + args.push("--branch"); + } + match git_stdout(&args) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git status failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git diff` with optional --cached, commit, and path filters. +/// Returns the diff output wrapped in a JSON object. +fn run_git_diff(input: GitDiffInput) -> Result { + let mut args: Vec = vec!["diff".to_string()]; + if input.staged.unwrap_or(false) { + args.push("--cached".to_string()); + } + if let Some(ref commit) = input.commit { + if let Some(ref commit2) = input.commit2 { + args.push(format!("{commit}...{commit2}")); + } else { + args.push(commit.clone()); + } + } + if let Some(ref path) = input.path { + args.push("--".to_string()); + args.push(path.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git diff failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git log` with count, author, date, and path filters. +/// Defaults to the last 20 commits. +fn run_git_log(input: GitLogInput) -> Result { + let mut args: Vec = vec!["log".to_string()]; + let count = input.count.unwrap_or(20); + args.push(format!("-n{count}")); + if input.oneline.unwrap_or(false) { + args.push("--oneline".to_string()); + } + if let Some(ref author) = input.author { + args.push(format!("--author={author}")); + } + if let Some(ref since) = input.since { + args.push(format!("--since={since}")); + } + if let Some(ref until) = input.until { + args.push(format!("--until={until}")); + } + if let Some(ref path) = input.path { + args.push("--".to_string()); + args.push(path.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git log failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git show` for a given commit, optionally with --stat or a file path. +/// Uses the `commit:path` syntax when a path is specified. +fn run_git_show(input: GitShowInput) -> Result { + let mut args: Vec = vec!["show".to_string()]; + if input.stat.unwrap_or(false) { + args.push("--stat".to_string()); + } + if let Some(ref path) = input.path { + args.push(format!("{}:{}", input.commit, path)); + } else { + args.push(input.commit.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err(format!("git show {} failed. Ensure the commit exists.", input.commit)), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git blame` on a file, optionally restricted to a line range. +fn run_git_blame(input: GitBlameInput) -> Result { + let mut args: Vec = vec!["blame".to_string()]; + if let (Some(start), Some(end)) = (input.start_line, input.end_line) { + args.push(format!("-L{start},{end}")); + } + args.push(input.path.clone()); + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err(format!("git blame {} failed. Ensure the file exists and the directory is inside a git repository.", input.path)), + } +} + fn from_value Deserialize<'de>>(input: &Value) -> Result { serde_json::from_value(input.clone()).map_err(|error| error.to_string()) } @@ -2547,6 +2743,85 @@ struct TestingPermissionInput { action: String, } +/// Input for the GitStatus tool: shows working tree status. +/// Defaults to --short --branch mode for concise, parseable output. +#[derive(Debug, Deserialize)] +struct GitStatusInput { + #[serde(default)] + /// If true, use --short --branch format. Defaults to true. + short: Option, +} + +/// Input for the GitDiff tool: shows changes between commits, index, and working tree. +/// All fields are optional - calling with no options is equivalent to `git diff`. +#[derive(Debug, Deserialize)] +struct GitDiffInput { + #[serde(default)] + /// File path to diff. Prepends `--` before the path. + path: Option, + #[serde(default)] + /// If true, show staged changes (`git diff --cached`). + staged: Option, + #[serde(default)] + /// A commit hash, tag, or branch to diff against. + commit: Option, + #[serde(default)] + /// A second commit for range diffs (commit...commit2). + commit2: Option, +} + +/// Input for the GitLog tool: shows commit history. +/// Defaults to the last 20 commits in full format. +#[derive(Debug, Deserialize)] +struct GitLogInput { + #[serde(default)] + /// File or directory path to filter commits by. + path: Option, + #[serde(default)] + /// Maximum number of commits to return. Defaults to 20. + count: Option, + #[serde(default)] + /// If true, use --oneline format (hash + subject only). + oneline: Option, + #[serde(default)] + /// Filter commits by author pattern. + author: Option, + #[serde(default)] + /// Filter commits since date (e.g. "2024-01-01" or "2.weeks"). + since: Option, + #[serde(default)] + /// Filter commits until date. + until: Option, +} + +/// Input for the GitShow tool: shows a commit, tag, or tree object. +#[derive(Debug, Deserialize)] +struct GitShowInput { + /// Commit hash, tag, or branch ref to show. Required. + commit: String, + #[serde(default)] + /// If set, show only this file at the given commit (commit:path syntax). + path: Option, + #[serde(default)] + /// If true, show diffstat summary instead of full diff. + stat: Option, +} + +/// Input for the GitBlame tool: shows per-line author/revision info for a file. +#[derive(Debug, Deserialize)] +struct GitBlameInput { + /// File path to blame. Required. + path: String, + #[serde(rename = "start_line")] + #[serde(default)] + /// Start of line range (1-based). Only used if end_line is also set. + start_line: Option, + #[serde(rename = "end_line")] + #[serde(default)] + /// End of line range (1-based). Only used if start_line is also set. + end_line: Option, +} + #[derive(Debug, Serialize)] struct WebFetchOutput { bytes: usize, From 312444f3a11ca824340e8ab013e8009c61c3e23b Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:32:40 -0500 Subject: [PATCH 2/8] 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 a810502e66..91b3733dd1 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -670,6 +670,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -715,6 +716,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 d4f1770673..7273fbf6f9 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 6b1408d0f97d10cfb8a12485ea115bc304d50caa Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:40:25 -0500 Subject: [PATCH 3/8] 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 6d8cdb83e6a8865c0acdc7d5d103cfab24e562fa Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:42:46 -0500 Subject: [PATCH 4/8] 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 91b3733dd1..2c3739beb7 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1299,6 +1299,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 c5d60298227fcd926ab93ca603ee5ba6a200f746 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:46:32 -0500 Subject: [PATCH 5/8] 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 2c3739beb7..10c6fb1db2 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1262,7 +1262,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 214f61d37374b035214809ec50ee5c95cf29cd7d Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:04:43 -0500 Subject: [PATCH 6/8] 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 --- .../crates/api/src/providers/openai_compat.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 10c6fb1db2..b8324c4594 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -493,7 +493,14 @@ impl StreamState { } for choice in chunk.choices { - if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) { + // Handle content from various fields (content, reasoning_content, thinking.content) + let content = choice + .delta + .content + .or(choice.delta.reasoning_content) + .or(choice.delta.thinking.and_then(|t| t.content)) + .filter(|value| !value.is_empty()); + if let Some(text) = content { if !self.text_started { self.text_started = true; events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent { @@ -505,7 +512,7 @@ impl StreamState { } events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 0, - delta: ContentBlockDelta::TextDelta { text: content }, + delta: ContentBlockDelta::TextDelta { text }, })); } @@ -737,10 +744,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 1adea4ee9c904f0afcf9fd9f283a4a87b5d09021 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:07:47 -0500 Subject: [PATCH 7/8] 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 b8324c4594..d2ff75dd84 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -735,6 +735,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, From fd03455567a26dc15f0b0eaeb94c9ce84e0d81e4 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 22:36:37 -0500 Subject: [PATCH 8/8] 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 3e805dda96..0048845667 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];