Compare commits

..
10 Commits
Author SHA1 Message Date
rkw6086 1845b9e20e Fix child startup readiness and ordered provider tools
Wait for shell bootstrap before dispatching child prompts, serialize provider preprocessing, and make permission callbacks idempotent. Restrict task lists to concrete multistep plans and stop inferring completion from tool activity.

Update regression fixtures and resolve existing lint and test-layout failures. Verified formatting, both presubmit Clippy commands, and 354 targeted nextest tests.
2026-09-09 13:55:34 -05:00
Ryan Ward 4b703c019f Use LLM-authored agent todos 2026-09-03 09:29:22 -05:00
Ryan Ward 7a33cc7e56 Improve agent provider resilience 2026-09-02 17:09:08 -05:00
Ryan Ward b115946534 Reduce session logging and background churn 2026-09-02 15:13:24 -05:00
rkw6086 13cbb232ee Merge branch 'feat/project-indexing' 2026-08-31 09:56:50 -05:00
rkw6086 9918c8ee39 finalizing 2026-08-31 09:54:59 -05:00
rkw6086 4a3a6c7848 Honor ChatGPT model maximum context windows 2026-08-31 00:10:45 -05:00
rkw6086 1c7d3c175d Add local project indexing and search guidance 2026-08-30 22:00:27 -05:00
rkw6086 e8166012f4 Improve large diff and merge conflict workflows 2026-08-28 21:08:55 -05:00
rkw6086 88c1ef9716 Improve local tool execution and shell output panes 2026-08-28 10:20:16 -05:00
174 changed files with 5928 additions and 1685 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifec
- `types.rs` — `ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition`
- `mod.rs` — `ProviderConfig` enum (Bedrock | OpenAI | None)
**Bedrock provider** in `app/src/ai/bedrock/`:
**Bedrock provider** in `app/src/ai/provider/`:
- `runtime.rs` — Native one-call `AgentRuntime` over `ConverseStream`, including cancellation, reasoning signatures, token usage, and stop/error classification
- `request_translator.rs` — Shared Bedrock message sanitization and tool definitions
- `response_translator.rs` — Compatibility conversion helpers used by tests and background flows
Generated
+2
View File
@@ -229,6 +229,7 @@ dependencies = [
"dirs 6.0.0",
"dunce",
"filetime",
"fs4",
"futures",
"galaxy_core",
"galaxy_graphql",
@@ -275,6 +276,7 @@ dependencies = [
"uuid",
"virtual-fs",
"warp_multi_agent_api",
"warp_search_core",
"watcher",
]
+1
View File
@@ -178,6 +178,7 @@ font-kit = { git = "https://github.com/warpdotdev/font-kit.git", rev = "a04b225e
futures = { version = "0.3", features = ["executor", "thread-pool"] }
futures-lite = "1.13.0"
futures-util = { version = "0.3", default-features = false }
fs4 = "0.13.1"
get-size = { version = "0.1.4", features = ["derive"] }
globset = "0.4.18"
gloo = { version = "0.11.0", default-features = false, features = [
+9 -9
View File
@@ -56,11 +56,11 @@ This is a Rust-based terminal emulator with a custom UI framework called **Galax
### AI Architecture
Galaxy uses **Amazon Bedrock** as the sole AI provider. The client calls Bedrock directly:
- `app/src/ai/bedrock/client.rs` - AWS Bedrock client (`BedrockClient::converse_stream`)
- `app/src/ai/bedrock/convert_request.rs` - System prompt construction, message/tool extraction
- `app/src/ai/bedrock/convert.rs` - Conversion to Bedrock wire format
- `app/src/ai/bedrock/tool_docs.rs` - Tool documentation (served via `get_tool_documentation` meta-tool)
- `app/src/ai/bedrock/stream.rs` - Response stream processing
- `app/src/ai/provider/client.rs` - AWS Bedrock client (`BedrockClient::converse_stream`)
- `app/src/ai/provider/convert_request.rs` - System prompt construction, message/tool extraction
- `app/src/ai/provider/convert.rs` - Conversion to Bedrock wire format
- `app/src/ai/provider/tool_docs.rs` - Tool documentation (served via `get_tool_documentation` meta-tool)
- `app/src/ai/provider/stream.rs` - Response stream processing
System prompt is dynamically built from request context (OS, shell, pwd, git, project rules, global rules, skills, MCP servers).
@@ -68,7 +68,7 @@ Global rules are loaded from `~/.galaxy-ai/rules/*.md` (filename = rule name, co
Project rules are loaded from `GALAXY.md` or `AGENTS.md` files found in the project directory tree.
**Model Discovery** (`app/src/ai/bedrock/discovery.rs`):
**Model Discovery** (`app/src/ai/provider/discovery.rs`):
At startup (and on manual refresh from the Bedrock settings page), Galaxy discovers available models using the user-selected auth settings (profile/SSO/static keys — no external config fallback) via:
1. **STS GetCallerIdentity** — validates AWS credentials before proceeding
2. **ListInferenceProfiles** (system-defined) + **ListFoundationModels** (TEXT output, ON_DEMAND) — fetched in parallel; results are deduplicated by underlying foundation model ID with inference profiles taking priority (they include cross-region routing)
@@ -80,14 +80,14 @@ The settings page refresh (`RefreshAwsBedrock` in `app/src/settings_view/ai_page
The `[1m]` suffix is an internal marker stripped by `strip_context_marker()` in `client.rs` before API calls. It's used by `context_window_for_model()` in `response_translator.rs` to report the correct context window size.
**External Config Fallback** (`app/src/ai/bedrock/external_config.rs`):
**External Config Fallback** (`app/src/ai/provider/external_config.rs`):
When Galaxy's own Bedrock settings are at defaults, it falls back to configurations from:
1. **Claude Code** (`~/.claude/settings.json`) — reads `env.AWS_PROFILE`, `env.AWS_REGION`, and `env.DCP_MODEL_MAP` (ARN-based model mappings)
2. **OpenCode** (`~/.config/opencode/opencode.json`) — reads `provider.amazon-bedrock.options.profile` and `.region`
Priority: Galaxy explicit settings > Claude Code > OpenCode > hardcoded defaults. Fallback only applies when profile is `"default"` (for profile) or empty (for region/models). External model ARNs are merged with Galaxy's built-in default model list.
**Token Usage & Cost Tracking** (`app/src/ai/bedrock/response_translator.rs`):
**Token Usage & Cost Tracking** (`app/src/ai/provider/response_translator.rs`):
The Bedrock stream extracts full token metadata from responses: `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_write_input_tokens`. These flow through `build_stream_finished` → `TokenUsage` struct → `conversation.update_cost_and_usage_for_request()`. Cost is estimated per-model using Bedrock pricing. Displayed in:
- **Agent management cards** — total token count in metadata row
- **Conversation usage footer** — full breakdown (input/output/cache read/cache write) + estimated cost
@@ -100,7 +100,7 @@ When context window usage reaches 85%, Galaxy automatically summarizes older mes
- No UI shown — user only sees context usage drop
- `recall_tool_history` tool lets the agent retrieve past tool outputs that were summarized away
**Failed Tool Call Visibility** (`app/src/ai/bedrock/response_translator.rs`):
**Failed Tool Call Visibility** (`app/src/ai/provider/response_translator.rs`):
When the model calls an unknown/hallucinated tool name, the response translator now emits a visible `AgentOutput` text message to the UI (via `build_add_agent_output_message`) showing what tool was attempted and the error. Previously, synthetic error results were only stored in history (for Bedrock message ordering) but never rendered.
**Loop Prevention Guardrail** (`app/src/ai/blocklist/controller.rs`):
+19 -19
View File
@@ -194,18 +194,18 @@ This is the primary new feature. A complete AWS Bedrock direct-call path runs al
| File | Purpose |
|---|---|
| `app/src/ai/bedrock/diagnostic.rs` | `BedrockDiagnosticLogger` — file-based diagnostic logging, activated via `GALAXY_BEDROCK_DIAGNOSTICS=1`, 10MB rotation |
| `app/src/ai/bedrock/e2e_tests.rs` | Comprehensive E2E integration tests (requires `BEDROCK_INTEGRATION_TEST` env var) |
| `app/src/ai/bedrock/integration_tests.rs` | Unit-level stream output collection tests |
| `app/src/ai/provider/diagnostic.rs` | `BedrockDiagnosticLogger` — file-based diagnostic logging, activated via `GALAXY_BEDROCK_DIAGNOSTICS=1`, 10MB rotation |
| `app/src/ai/provider/e2e_tests.rs` | Comprehensive E2E integration tests (requires `BEDROCK_INTEGRATION_TEST` env var) |
| `app/src/ai/provider/integration_tests.rs` | Unit-level stream output collection tests |
### Modified Files
| File | Key Change |
|---|---|
| `app/src/ai/bedrock/client.rs` | Added `needs_create_task: bool` and `diagnostic_logger` params to `converse_stream()` |
| `app/src/ai/bedrock/convert.rs` | Added `Clone`, `Debug`, `PartialEq` derives to core types |
| `app/src/ai/bedrock/convert_request.rs` | New input type handlers: `InitProjectRules`, `CreateEnvironment`, `CreateNewProject`, `CloneRepository`, `AutoCodeDiffQuery`, `ResumeConversation`, `QueryWithCannedResponse`, `CodeReview` |
| `app/src/ai/bedrock/stream.rs` | Buffered text flush threshold, `needs_create_task` / `CreateTask` event, reasoning content discarded |
| `app/src/ai/provider/client.rs` | Added `needs_create_task: bool` and `diagnostic_logger` params to `converse_stream()` |
| `app/src/ai/provider/convert.rs` | Added `Clone`, `Debug`, `PartialEq` derives to core types |
| `app/src/ai/provider/convert_request.rs` | New input type handlers: `InitProjectRules`, `CreateEnvironment`, `CreateNewProject`, `CloneRepository`, `AutoCodeDiffQuery`, `ResumeConversation`, `QueryWithCannedResponse`, `CodeReview` |
| `app/src/ai/provider/stream.rs` | Buffered text flush threshold, `needs_create_task` / `CreateTask` event, reasoning content discarded |
| `app/src/ai/agent/api/impl.rs` | `needs_create_task` detection, diagnostic logger wiring, verbose debug logging |
| `app/src/ai/llms.rs` | Bedrock models added to `coding` and `cli_agent` feature slots (previously only `agent_mode`) |
@@ -220,26 +220,26 @@ This is the primary new feature. A complete AWS Bedrock direct-call path runs al
**`needs_create_task` detection logic** (`app/src/ai/agent/api/impl.rs`)
Detected via `task_context.tasks.is_empty()`. If the tasks list is populated with a placeholder or shell task entry before the first real task is submitted, this flag will be `false` and `CreateTask` will never be emitted, silently breaking the task creation flow for Bedrock on the first turn.
**E2E test tool_use_id mismatch** (`app/src/ai/bedrock/e2e_tests.rs`)
**E2E test tool_use_id mismatch** (`app/src/ai/provider/e2e_tests.rs`)
In `test_agent_multi_turn_tool_use_produces_output`, the test synthesizes tool result IDs as `tool_{total_turns}`, but the actual tool call ID emitted from the stream is a different value. This means the tool_call_id round-trip assertion will fail or be skipped silently. The test may pass vacuously.
**`ensure_tool_results_paired()` synthesized results** (`app/src/ai/bedrock/convert_request.rs`)
**`ensure_tool_results_paired()` synthesized results** (`app/src/ai/provider/convert_request.rs`)
This function inserts placeholder tool results for orphaned tool calls. The synthesized content is empty/default, which could confuse the model on subsequent turns. The synthesized result should include a meaningful message like `"Tool result not available"` rather than empty content.
#### 🟡 Medium
**`TEXT_FLUSH_THRESHOLD = 20`** (`app/src/ai/bedrock/stream.rs`)
**`TEXT_FLUSH_THRESHOLD = 20`** (`app/src/ai/provider/stream.rs`)
Text fragments shorter than 20 characters before a tool call boundary are silently discarded. This can drop model preamble text such as `"Let me look at that..."` or `"OK."`. Consider buffering until a natural boundary (tool call or stop event) rather than a character threshold, or surface buffered content even if short.
**`converse_stream()` wide parameter surface** (`app/src/ai/bedrock/client.rs`)
**`converse_stream()` wide parameter surface** (`app/src/ai/provider/client.rs`)
The function now takes 9 parameters including `needs_create_task` in the middle of the list. This is fragile and hard to read at call sites. Recommend grouping into a `BedrockConversationConfig` struct.
**Diagnostic logger privacy concern** (`app/src/ai/bedrock/diagnostic.rs`)
**Diagnostic logger privacy concern** (`app/src/ai/provider/diagnostic.rs`)
`log_protobuf_input` uses Rust's `{:?}` debug format rather than structured JSON. This is inconsistent with the rest of the logger (which emits JSON) and may dump sensitive content (full conversation history, file contents) to disk in an unstructured format. Consider either JSON serialization or explicit redaction.
#### 🟢 Low
**Reasoning content silently discarded** (`app/src/ai/bedrock/stream.rs`)
**Reasoning content silently discarded** (`app/src/ai/provider/stream.rs`)
Reasoning/thinking content from models that emit it (e.g., Claude 3.7 extended thinking) is currently discarded. This is an intentional product decision but should be documented in a comment so future contributors understand why.
**`diagnostic.rs` log path** — Uses `warp_logging::log_directory()`. The log file will land in the `warp_logging`-configured directory. Verify this resolves to the correct Galaxy AI log directory on all platforms.
@@ -316,7 +316,7 @@ All internal crates retain `warp_*` naming:
|---|---|---|
| C1 | `WARP_INTEGRATION` / `GALAXY_INTEGRATION` split — breaks integration test detection at runtime | `app/src/lib.rs:658` |
| C2 | Windows TTY env vars completely skipped — Windows shell integration broken | `app/src/terminal/local_tty/windows/environment.rs` |
| C3 | E2E test tool_use_id mismatch — multi-turn tool use test may be vacuously passing | `app/src/ai/bedrock/e2e_tests.rs` |
| C3 | E2E test tool_use_id mismatch — multi-turn tool use test may be vacuously passing | `app/src/ai/provider/e2e_tests.rs` |
### 🟠 High — Fix Before Beta
@@ -335,11 +335,11 @@ All internal crates retain `warp_*` naming:
| # | Issue | File(s) |
|---|---|---|
| M1 | All `warp.dev` URLs untouched — wrong privacy policy, docs, and external links | `app/src/util/links.rs` + 7 other files |
| M2 | `converse_stream()` 9-param signature — should use config struct | `app/src/ai/bedrock/client.rs` |
| M3 | `TEXT_FLUSH_THRESHOLD = 20` may silently discard model preamble text | `app/src/ai/bedrock/stream.rs` |
| M4 | `ensure_tool_results_paired()` synthesizes empty tool results — may confuse model | `app/src/ai/bedrock/convert_request.rs` |
| M2 | `converse_stream()` 9-param signature — should use config struct | `app/src/ai/provider/client.rs` |
| M3 | `TEXT_FLUSH_THRESHOLD = 20` may silently discard model preamble text | `app/src/ai/provider/stream.rs` |
| M4 | `ensure_tool_results_paired()` synthesizes empty tool results — may confuse model | `app/src/ai/provider/convert_request.rs` |
| M5 | `WARP_CHANNEL_VERSIONS_PATH` not renamed in autoupdate | `app/src/autoupdate/` |
| M6 | Diagnostic logger `log_protobuf_input` uses debug format, not JSON | `app/src/ai/bedrock/diagnostic.rs` |
| M6 | Diagnostic logger `log_protobuf_input` uses debug format, not JSON | `app/src/ai/provider/diagnostic.rs` |
| M7 | `WARP_USER_SECRET` build-time env not renamed | `app/src/auth/auth_state.rs:107` |
| M8 | `DEFAULT_UI_FONT_NAME = ""` sentinel is a code smell | `app/src/settings/font.rs` |
| M9 | Referral theme display names still say `"Warp Referral"` | `app/src/themes/theme.rs` |
@@ -351,7 +351,7 @@ All internal crates retain `warp_*` naming:
| # | Issue | File(s) |
|---|---|---|
| L1 | Stale comment: *"Use the word 'Warp'..."* | `app/src/workspace/view.rs:546` |
| L2 | Reasoning content discard should be documented with a comment | `app/src/ai/bedrock/stream.rs` |
| L2 | Reasoning content discard should be documented with a comment | `app/src/ai/provider/stream.rs` |
| L3 | `warp_*` crate naming — document intent (internal stable vs. rebrand needed) | All `Cargo.toml` files |
| L4 | `apply_samsung_brand_preset()` full implementation not reviewed | `app/src/settings_view/appearance_page.rs` |
| L5 | `WARP.md` build commands still reference `cargo bundle --bin warp` — should be updated | `WARP.md` |
+2 -118
View File
@@ -270,121 +270,5 @@ pub(crate) enum AcpRuntimeModelEvent {
impl SingletonEntity for AcpRuntimeModel {}
#[cfg(test)]
mod tests {
use galaxy_acp::{SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption};
use super::{AcpDiscoveryState, AcpRuntimeModel};
use crate::settings::{AcpAgentSettings, AcpConfigOptionSettings};
#[test]
fn normalize_config_options_preserves_values_and_order() {
let options = vec![
SessionConfigOption::select(
"model",
"Model",
"fast",
vec![
SessionConfigSelectOption::new("fast", "Fast"),
SessionConfigSelectOption::new("accurate", "Accurate"),
],
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::boolean("thinking", "Thinking", true),
];
let normalized = AcpRuntimeModel::normalize_config_options(options);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, "select");
assert_eq!(normalized[0].current_value, serde_json::json!("fast"));
assert_eq!(normalized[0].options[0].value, serde_json::json!("fast"));
assert_eq!(
normalized[0].options[1].value,
serde_json::json!("accurate")
);
assert_eq!(normalized[1].kind, "boolean");
assert_eq!(normalized[1].current_value, serde_json::json!(true));
}
#[test]
fn normalize_config_options_preserves_unknown_categories() {
let option = SessionConfigOption::select(
"custom",
"Custom",
"one",
Vec::<SessionConfigSelectOption>::new(),
)
.category(SessionConfigOptionCategory::Other("_custom".to_owned()));
let normalized = AcpRuntimeModel::normalize_config_options(vec![option]);
assert_eq!(normalized[0].category.as_deref(), Some("_custom"));
}
#[test]
fn selection_replaces_model_and_preserves_other_current_values() {
let options = vec![
AcpConfigOptionSettings {
id: "model".to_owned(),
name: "Model".to_owned(),
description: None,
category: Some("model".to_owned()),
kind: "select".to_owned(),
current_value: serde_json::json!("fast"),
options: Vec::new(),
},
AcpConfigOptionSettings {
id: "thinking".to_owned(),
name: "Thinking".to_owned(),
description: None,
category: None,
kind: "boolean".to_owned(),
current_value: serde_json::json!(true),
options: Vec::new(),
},
];
assert_eq!(
AcpRuntimeModel::selection_values_for_model(
&options,
"model",
&serde_json::json!("accurate")
),
std::collections::BTreeMap::from([
("model".to_owned(), serde_json::json!("accurate")),
("thinking".to_owned(), serde_json::json!(true)),
])
);
}
#[test]
fn discovery_state_has_explicit_running_success_and_failure_values() {
assert_eq!(AcpDiscoveryState::default(), AcpDiscoveryState::Idle);
assert_eq!(
AcpDiscoveryState::Succeeded { option_count: 2 },
AcpDiscoveryState::Succeeded { option_count: 2 }
);
assert_eq!(
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
},
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
}
);
}
#[test]
fn discovery_metadata_fields_are_optional_for_legacy_settings() {
let json = serde_json::json!({
"id": "codex",
"name": "Codex",
"config_options": []
});
let agent: AcpAgentSettings = serde_json::from_value(json).unwrap();
assert!(agent.discovery_timestamp.is_none());
assert!(agent.discovery_source.is_none());
assert!(agent.discovery_error.is_none());
}
}
#[path = "runtime_model_tests.rs"]
mod tests;
+116
View File
@@ -0,0 +1,116 @@
use galaxy_acp::{SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption};
use super::{AcpDiscoveryState, AcpRuntimeModel};
use crate::settings::{AcpAgentSettings, AcpConfigOptionSettings};
#[test]
fn normalize_config_options_preserves_values_and_order() {
let options = vec![
SessionConfigOption::select(
"model",
"Model",
"fast",
vec![
SessionConfigSelectOption::new("fast", "Fast"),
SessionConfigSelectOption::new("accurate", "Accurate"),
],
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::boolean("thinking", "Thinking", true),
];
let normalized = AcpRuntimeModel::normalize_config_options(options);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, "select");
assert_eq!(normalized[0].current_value, serde_json::json!("fast"));
assert_eq!(normalized[0].options[0].value, serde_json::json!("fast"));
assert_eq!(
normalized[0].options[1].value,
serde_json::json!("accurate")
);
assert_eq!(normalized[1].kind, "boolean");
assert_eq!(normalized[1].current_value, serde_json::json!(true));
}
#[test]
fn normalize_config_options_preserves_unknown_categories() {
let option = SessionConfigOption::select(
"custom",
"Custom",
"one",
Vec::<SessionConfigSelectOption>::new(),
)
.category(SessionConfigOptionCategory::Other("_custom".to_owned()));
let normalized = AcpRuntimeModel::normalize_config_options(vec![option]);
assert_eq!(normalized[0].category.as_deref(), Some("_custom"));
}
#[test]
fn selection_replaces_model_and_preserves_other_current_values() {
let options = vec![
AcpConfigOptionSettings {
id: "model".to_owned(),
name: "Model".to_owned(),
description: None,
category: Some("model".to_owned()),
kind: "select".to_owned(),
current_value: serde_json::json!("fast"),
options: Vec::new(),
},
AcpConfigOptionSettings {
id: "thinking".to_owned(),
name: "Thinking".to_owned(),
description: None,
category: None,
kind: "boolean".to_owned(),
current_value: serde_json::json!(true),
options: Vec::new(),
},
];
assert_eq!(
AcpRuntimeModel::selection_values_for_model(
&options,
"model",
&serde_json::json!("accurate")
),
std::collections::BTreeMap::from([
("model".to_owned(), serde_json::json!("accurate")),
("thinking".to_owned(), serde_json::json!(true)),
])
);
}
#[test]
fn discovery_state_has_explicit_running_success_and_failure_values() {
assert_eq!(AcpDiscoveryState::default(), AcpDiscoveryState::Idle);
assert_eq!(
AcpDiscoveryState::Succeeded { option_count: 2 },
AcpDiscoveryState::Succeeded { option_count: 2 }
);
assert_eq!(
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
},
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
}
);
}
#[test]
fn discovery_metadata_fields_are_optional_for_legacy_settings() {
let json = serde_json::json!({
"id": "codex",
"name": "Codex",
"config_options": []
});
let agent: AcpAgentSettings = serde_json::from_value(json).unwrap();
assert!(agent.discovery_timestamp.is_none());
assert!(agent.discovery_source.is_none());
assert!(agent.discovery_error.is_none());
}
+4 -1
View File
@@ -834,7 +834,10 @@ pub(crate) fn convert_tool_call_result_to_input(
})
.collect();
GrepResult::Success { matched_files }
GrepResult::Success {
matched_files,
truncated: false,
}
}
Some(api::grep_result::Result::Error(error)) => {
GrepResult::Error(error.message.clone())
@@ -2145,7 +2145,7 @@ fn test_internal_command_completion_assessment_restores_output_without_visible_i
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let provider_history =
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
crate::ai::provider::request_translator::convert_proto_message(&hidden_assessment)
.expect("hidden assessment should remain in provider history");
assert_eq!(
provider_history.role,
+11 -11
View File
@@ -334,8 +334,8 @@ pub struct AIConversation {
/// Whether the user has pinned this child agent in the orchestration
/// pill bar. Persisted via `AgentConversationData.pinned`.
pinned: bool,
bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
bedrock_message_history: Vec<crate::ai::provider::convert::ConversationMessage>,
tool_result_archive: Vec<crate::ai::provider::convert::ConversationMessage>,
progressive_summary: Option<String>,
messages_summarized_up_to: usize,
current_context_tokens: u32,
@@ -448,10 +448,10 @@ impl AIConversation {
tasks: Vec<api::Task>,
conversation_data: Option<AgentConversationData>,
) -> Result<Self, RestoreConversationError> {
let bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage> = tasks
let bedrock_message_history: Vec<crate::ai::provider::convert::ConversationMessage> = tasks
.iter()
.flat_map(|task| task.messages.iter())
.filter_map(crate::ai::bedrock::request_translator::convert_proto_message)
.filter_map(crate::ai::provider::request_translator::convert_proto_message)
.collect();
let (task_store, todo_lists, status) = if tasks.is_empty() {
@@ -800,32 +800,32 @@ impl AIConversation {
self.has_pending_progressive_summary = val;
}
pub fn bedrock_message_history(&self) -> &[crate::ai::bedrock::convert::ConversationMessage] {
pub fn bedrock_message_history(&self) -> &[crate::ai::provider::convert::ConversationMessage] {
&self.bedrock_message_history
}
pub fn bedrock_message_history_mut(
&mut self,
) -> &mut Vec<crate::ai::bedrock::convert::ConversationMessage> {
) -> &mut Vec<crate::ai::provider::convert::ConversationMessage> {
&mut self.bedrock_message_history
}
pub fn tool_result_archive(&self) -> &[crate::ai::bedrock::convert::ConversationMessage] {
pub fn tool_result_archive(&self) -> &[crate::ai::provider::convert::ConversationMessage] {
&self.tool_result_archive
}
pub fn archive_tool_results(
&mut self,
messages: Vec<crate::ai::bedrock::convert::ConversationMessage>,
messages: Vec<crate::ai::provider::convert::ConversationMessage>,
) {
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
use crate::ai::provider::convert::{ContentPart, MessageContent};
// Cap the archive to prevent unbounded growth. The archive is only used by
// `recall_tool_history` which already truncates individual results to 50K chars,
// so retaining the most recent entries is sufficient for lookup.
const MAX_TOOL_RESULT_ARCHIVE_ENTRIES: usize = 400;
let mut pending_tool_uses: Vec<crate::ai::bedrock::convert::ConversationMessage> =
let mut pending_tool_uses: Vec<crate::ai::provider::convert::ConversationMessage> =
Vec::new();
for msg in messages {
@@ -873,7 +873,7 @@ impl AIConversation {
pub fn append_to_bedrock_history(
&mut self,
messages: Vec<crate::ai::bedrock::convert::ConversationMessage>,
messages: Vec<crate::ai::provider::convert::ConversationMessage>,
) {
self.bedrock_message_history.extend(messages);
}
+10 -1
View File
@@ -1343,11 +1343,20 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
FileGlobV2Result::Cancelled => write!(f, "\n_File glob cancelled_"),
},
AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => {
GrepResult::Success {
matched_files,
truncated,
} => {
write!(f, "\n\n**Grep Results:**\n\n")?;
for file in matched_files {
writeln!(f, "- **{}**", file.file_path)?;
}
if *truncated {
writeln!(
f,
"\n_Additional matches omitted; narrow the query or path._"
)?;
}
Ok(())
}
GrepResult::Error(message) => {
+125 -18
View File
@@ -4,9 +4,14 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::{
LocalIndexStatus, LocalProjectIndexEvent, LocalProjectIndexManager,
};
use futures::channel::oneshot;
use futures::future::join_all;
use galaxy_cli::agent::Harness;
@@ -90,6 +95,9 @@ pub fn prepare_environment(
if should_subscribe_to_index_updates && result.is_err() {
let _ = spawner
.spawn(|_, ctx| {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
@@ -201,6 +209,9 @@ async fn prepare_environment_impl(
} else if should_index_codebase && source_repos.is_empty() {
let _ = spawner
.spawn(|_, ctx| {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
@@ -235,6 +246,9 @@ fn record_codebase_indexing(
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
let _ = spawner
.spawn(|_, ctx| {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
@@ -255,6 +269,9 @@ fn record_codebase_indexing(
}
let _ = spawner
.spawn(|_, ctx| {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
@@ -489,6 +506,7 @@ pub(super) async fn register_cloned_repo(
Ok(())
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
async fn subscribe_to_codebase_index_events(
spawner: &ModelSpawner<TerminalDriver>,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
@@ -496,11 +514,10 @@ async fn subscribe_to_codebase_index_events(
spawner
.spawn(move |_, ctx| {
let repo_channels = Arc::clone(&repo_channels);
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), move |_, _, event, ctx| {
if !matches!(
event,
CodebaseIndexManagerEvent::SyncStateUpdated { .. }
) {
ctx.subscribe_to_model(
&CodebaseIndexManager::handle(ctx),
move |_, _, event, ctx| {
if !matches!(event, CodebaseIndexManagerEvent::SyncStateUpdated { .. }) {
return;
}
@@ -511,22 +528,15 @@ async fn subscribe_to_codebase_index_events(
.expect("repo channel map lock should not be poisoned");
for repo in channels.keys() {
let Some(status) =
manager.get_codebase_index_status_for_path(repo, ctx)
let Some(status) = manager.get_codebase_index_status_for_path(repo, ctx)
else {
continue;
};
if status.has_synced_version() {
repos_to_notify.push(repo.clone());
continue;
}
if !status.has_pending() && status.last_sync_successful() == Some(false) {
safe_warn!(
safe: ("Codebase index sync failed for a repo; unblocking environment setup"),
full: ("Codebase index sync failed for {repo:?}; unblocking environment setup")
);
if status.has_synced_version()
|| (!status.has_pending()
&& status.last_sync_successful() == Some(false))
{
repos_to_notify.push(repo.clone());
}
}
@@ -536,12 +546,58 @@ async fn subscribe_to_codebase_index_events(
let _ = tx.send(());
}
}
});
},
);
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
async fn subscribe_to_codebase_index_events(
spawner: &ModelSpawner<TerminalDriver>,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
) -> Result<(), PrepareEnvironmentError> {
spawner
.spawn(move |_, ctx| {
let repo_channels = Arc::clone(&repo_channels);
ctx.subscribe_to_model(
&LocalProjectIndexManager::handle(ctx),
move |_, _, event, _ctx| {
if !matches!(event, LocalProjectIndexEvent::StatusChanged { .. }) {
return;
}
let mut repos_to_notify = Vec::new();
let mut channels = repo_channels
.lock()
.expect("repo channel map lock should not be poisoned");
for repo in channels.keys() {
// The manager event identifies the changed root, while the channel map can
// contain nested paths. Re-check each path so a root-level completion wakes
// the exact environment request that owns it.
if LocalProjectIndexManager::as_ref(_ctx).is_ready_for_path(repo)
|| matches!(
LocalProjectIndexManager::as_ref(_ctx).status_for_path(repo),
Some((_, LocalIndexStatus::Failed { .. }))
)
{
repos_to_notify.push(repo.clone());
}
}
for repo in repos_to_notify {
if let Some(tx) = channels.remove(&repo) {
let _ = tx.send(());
}
}
},
);
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
async fn index_repo_codebase(
repo_name: &str,
@@ -592,6 +648,57 @@ async fn index_repo_codebase(
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
async fn index_repo_codebase(
repo_name: &str,
working_dir: &Path,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<Option<oneshot::Receiver<()>>, PrepareEnvironmentError> {
let repo_path = working_dir.join(repo_name);
safe_info!(
safe: ("Trying to index repository for codebase context"),
full: ("Trying to index {:?} for codebase context", repo_path)
);
let repo_path_for_spawn = repo_path.clone();
spawner
.spawn(move |_, ctx| {
let (tx, rx) = oneshot::channel::<()>();
repo_channels
.lock()
.expect("repo channel map lock should not be poisoned")
.insert(repo_path_for_spawn.clone(), tx);
let indexing_result = LocalProjectIndexManager::handle(ctx)
.update(ctx, |manager, ctx| {
manager.index_directory(repo_path_for_spawn.clone(), ctx)
});
if let Err(error) = indexing_result {
log::warn!("Failed to start local project indexing: {error:#}");
repo_channels
.lock()
.expect("repo channel map lock should not be poisoned")
.remove(&repo_path_for_spawn);
return None;
}
if LocalProjectIndexManager::as_ref(ctx).is_ready_for_path(&repo_path_for_spawn) {
repo_channels
.lock()
.expect("repo channel map lock should not be poisoned")
.remove(&repo_path_for_spawn);
None
} else {
Some(rx)
}
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
/// Execute a command in the context of a terminal session.
async fn execute_command(
command: String,
+8 -2
View File
@@ -143,10 +143,16 @@ pub mod text {
SearchCodebaseResult::Cancelled => todo!(),
},
AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => {
GrepResult::Success {
matched_files,
truncated,
} => {
for file in matched_files {
writeln!(w, "- {file}")?;
}
if *truncated {
writeln!(w, "Additional matches omitted; narrow the query or path.")?;
}
Ok(())
}
GrepResult::Error(error) => writeln!(w, "grep failed: {error}"),
@@ -923,7 +929,7 @@ pub mod json {
SearchCodebaseResult::Cancelled => Some(JsonMessage::ToolCanceled),
},
AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => {
GrepResult::Success { matched_files, .. } => {
use crate::ai::agent::GrepFileMatch;
let files: Vec<JsonFile> = matched_files
.iter()
+6
View File
@@ -2,7 +2,10 @@ use std::path::Path;
use std::sync::LazyLock;
use std::time::Duration;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::LocalProjectIndexManager;
use markdown_parser::FormattedTextFragment;
use rand::seq::SliceRandom;
use warpui::keymap::Keystroke;
@@ -411,6 +414,9 @@ impl AITip for AgentTip {
else {
return true;
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
return !LocalProjectIndexManager::as_ref(app).is_ready_for_path(root);
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
return CodebaseIndexManager::as_ref(app)
.get_codebase_index_status_for_path(root, app)
.is_none();
-23
View File
@@ -1,23 +0,0 @@
pub mod client;
pub mod convert;
pub mod crash_log;
pub mod diagnostic;
pub mod discovery;
pub mod external_config;
pub mod models;
pub mod request_translator;
pub mod response_translator;
pub mod settings_view;
#[cfg(test)]
mod convert_tests;
#[cfg(test)]
#[allow(dead_code)]
mod e2e_tests;
#[cfg(test)]
#[allow(dead_code)]
mod integration_tests;
#[cfg(test)]
mod models_tests;
#[cfg(test)]
mod response_translator_tests;
-50
View File
@@ -1,50 +0,0 @@
#![allow(dead_code)]
use crate::settings::ai::BedrockModelConfig;
pub fn configured_model_uses_rig(
selected_model_id: &str,
configured_models: &[BedrockModelConfig],
region: &str,
cross_region_inference: bool,
) -> bool {
let selected_model_id = strip_context_marker(selected_model_id);
configured_models.iter().any(|model| {
if !model.use_rig {
return false;
}
let configured_model_id = strip_context_marker(&model.model_id);
if configured_model_id == selected_model_id {
return true;
}
galaxy_agent_rig::resolve_bedrock_model_id(&model.model_id, region, cross_region_inference)
.is_ok_and(|resolved| strip_context_marker(&resolved) == selected_model_id)
})
}
fn strip_context_marker(model_id: &str) -> &str {
model_id
.strip_suffix("[1m]")
.or_else(|| model_id.strip_suffix("[1M]"))
.unwrap_or(model_id)
}
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
if model_id.starts_with("arn:") {
return model_id.to_string();
}
if model_id.contains('.') && model_id.split('.').next().unwrap_or("").len() <= 6 {
return model_id.to_string();
}
let prefix = match region {
r if r.starts_with("us-") || r.starts_with("ca-") => "us",
r if r.starts_with("eu-") || r == "il-central-1" => "eu",
r if r == "ap-northeast-1" || r == "ap-northeast-3" => "jp",
r if r == "ap-southeast-2" || r == "ap-southeast-4" || r == "ap-southeast-6" => "au",
r if r.starts_with("ap-") => "apac",
_ => return model_id.to_string(),
};
format!("{}.{}", prefix, model_id)
}
+162 -16
View File
@@ -274,6 +274,18 @@ enum StartedAction {
Async { phase: RunningActionPhase },
}
impl StartedAction {
fn allows_compatible_following_actions(self) -> bool {
matches!(
self,
Self::Sync
| Self::Async {
phase: RunningActionPhase::Parallel(_)
}
)
}
}
/// Returns whether another action may join the currently running phase.
///
/// Parallel phases only admit additional actions that classify into the same group and
@@ -768,10 +780,15 @@ pub struct BlocklistAIActionModel {
/// Permission-card rejections whose cancelled action result must not emit a second provider event.
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
pending_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
/// Actions parked because their executor-specific UI or state was not ready yet.
not_ready_actions: NotReadyActionTracker,
/// Provider preprocessing waits for execution order, since it can read file contents.
/// The value is true while preprocessing is in flight.
deferred_provider_preprocessing: HashMap<(AIConversationId, AIAgentActionId), bool>,
/// Durable provider work identity for actions owned by an active provider run.
provider_tool_executions:
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
@@ -887,7 +904,9 @@ impl BlocklistAIActionModel {
running_actions: Default::default(),
action_order: Default::default(),
denied_permissions: Default::default(),
pending_permissions: Default::default(),
not_ready_actions: Default::default(),
deferred_provider_preprocessing: Default::default(),
provider_tool_executions: Default::default(),
terminal_view_id,
pending_preprocessed_actions: Default::default(),
@@ -1268,6 +1287,13 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
id: &AIAgentActionId,
) -> Option<AIActionStatus> {
if self
.deferred_provider_preprocessing
.get(&(conversation_id, id.clone()))
== Some(&true)
{
return Some(AIActionStatus::Preprocessing);
}
if let Some(status) = pending_action_status(
&self.pending_actions,
&self.running_actions,
@@ -1421,7 +1447,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User,
ctx,
)
.is_some_and(|result| matches!(result, StartedAction::Sync))
.is_some_and(StartedAction::allows_compatible_following_actions)
{
self.try_to_execute_available_actions(conversation_id, ctx);
}
@@ -1441,7 +1467,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User,
ctx,
)
.is_some_and(|result| matches!(result, StartedAction::Sync))
.is_some_and(StartedAction::allows_compatible_following_actions)
{
self.try_to_execute_available_actions(conversation_id, ctx);
}
@@ -1495,7 +1521,7 @@ impl BlocklistAIActionModel {
}
fn handle_not_executed_action(
&self,
&mut self,
action: &AIAgentAction,
reason: NotExecutedReason,
conversation_id: AIConversationId,
@@ -1503,6 +1529,12 @@ impl BlocklistAIActionModel {
) {
match reason {
NotExecutedReason::NeedsConfirmation => {
if !self
.pending_permissions
.insert((conversation_id, action.id.clone()))
{
return;
}
#[cfg(not(target_family = "wasm"))]
log_tool_event(
ctx,
@@ -1612,6 +1644,42 @@ impl BlocklistAIActionModel {
.get(&conversation_id)
.and_then(|queue| queue.iter().position(|action| &action.id == action_id))?;
let key = (conversation_id, action_id.clone());
if self.provider_tool_executions.contains_key(&key) && idx != 0 {
return None;
}
// Readiness callbacks also enter here directly, bypassing the normal queue loop.
// They must obey the same serial barriers before preprocessing or executing.
if let Some(current_phase) = self.action_execution_phase(conversation_id) {
let action = self.pending_actions[&conversation_id][idx].clone();
if !self.can_start_action_in_current_phase(&action, conversation_id, current_phase, ctx)
{
return None;
}
}
if let Some(in_flight) = self.deferred_provider_preprocessing.get_mut(&key) {
if *in_flight {
return None;
}
*in_flight = true;
let action = self.pending_actions[&conversation_id][idx].clone();
let execution_ref = self.provider_tool_execution_ref(conversation_id, action_id);
let future = self.preprocess_action(&action, conversation_id, ctx);
ctx.spawn(future, move |me, (), ctx| {
// A cancelled or replaced generation must never restart this action.
if me.provider_tool_execution_ref(conversation_id, &action.id) != execution_ref {
return;
}
me.deferred_provider_preprocessing.remove(&key);
if is_user_initiated {
me.execute_action(&action.id, conversation_id, ctx);
} else {
me.try_to_execute_available_actions(conversation_id, ctx);
}
});
return None;
}
let action = self
.pending_actions
.get_mut(&conversation_id)?
@@ -1628,7 +1696,13 @@ impl BlocklistAIActionModel {
"permission_kind": format!("{:?}", permission_kind_for_action(&action.action)),
"phase": format!("{phase:?}"),
});
if is_user_initiated {
let resolves_permission = self.pending_permissions.contains(&key)
&& (is_user_initiated
|| self.executor.update(ctx, |executor, ctx| {
executor.can_autoexecute_action(&action, conversation_id, ctx)
}));
if resolves_permission {
self.pending_permissions.remove(&key);
#[cfg(not(target_family = "wasm"))]
log_tool_event(
ctx,
@@ -1742,23 +1816,77 @@ impl BlocklistAIActionModel {
batch: &PendingToolBatch,
ctx: &mut ModelContext<Self>,
) -> Result<(), ProviderActionQueueError> {
let refs = provider_action_correlations(&actions, conversation_id, batch)?;
for ((_, action_id), _) in &refs {
if self
.provider_tool_executions
.contains_key(&(conversation_id, action_id.clone()))
let action_ids = self.provider_action_ids_to_enqueue(&actions, conversation_id, batch)?;
let refs = provider_action_correlations(&actions, conversation_id, batch)?
.into_iter()
.filter(|((_, action_id), _)| action_ids.contains(action_id));
self.provider_tool_executions.extend(refs);
self.executor.update(ctx, |executor, ctx| {
executor.mark_restored_actions(conversation_id, &recovery_action_ids, ctx);
});
let actions: Vec<AIAgentAction> = actions
.into_iter()
.filter(|action| action_ids.contains(&action.id))
.collect();
if !actions.is_empty() {
self.queue_actions(actions, conversation_id, ctx);
}
Ok(())
}
pub(super) fn provider_action_ids_to_enqueue(
&self,
actions: &[AIAgentAction],
conversation_id: AIConversationId,
batch: &PendingToolBatch,
) -> Result<HashSet<AIAgentActionId>, ProviderActionQueueError> {
Self::provider_action_ids_to_enqueue_from(
&self.provider_tool_executions,
actions,
conversation_id,
batch,
)
}
fn provider_action_ids_to_enqueue_from(
existing_correlations: &HashMap<
(AIConversationId, AIAgentActionId),
ProviderToolExecutionRef,
>,
actions: &[AIAgentAction],
conversation_id: AIConversationId,
batch: &PendingToolBatch,
) -> Result<HashSet<AIAgentActionId>, ProviderActionQueueError> {
let refs = provider_action_correlations(actions, conversation_id, batch)?;
let mut action_ids = HashSet::with_capacity(refs.len());
let mut seen_refs = HashSet::with_capacity(refs.len());
for ((_, action_id), execution_ref) in &refs {
if !seen_refs.insert(execution_ref.clone()) {
return Err(ProviderActionQueueError::ExistingCorrelation {
call_id: action_id.to_string(),
});
}
if let Some(existing_ref) =
existing_correlations.get(&(conversation_id, action_id.clone()))
{
if existing_ref == execution_ref {
continue;
}
return Err(ProviderActionQueueError::ExistingCorrelation {
call_id: action_id.to_string(),
});
}
if existing_correlations
.values()
.any(|existing_ref| existing_ref == execution_ref)
{
return Err(ProviderActionQueueError::ExistingCorrelation {
call_id: action_id.to_string(),
});
}
action_ids.insert(action_id.clone());
}
self.provider_tool_executions.extend(refs);
self.executor.update(ctx, |executor, ctx| {
executor.mark_restored_actions(conversation_id, &recovery_action_ids, ctx);
});
self.queue_actions(actions, conversation_id, ctx);
Ok(())
Ok(action_ids)
}
/// Queues the `actions` in the given iterator for the given conversation,
@@ -1818,7 +1946,15 @@ impl BlocklistAIActionModel {
for action in actions.iter() {
action_ids.insert(action.id.clone());
preprocess_future.push(self.preprocess_action(action, conversation_id, ctx));
if self
.provider_tool_executions
.contains_key(&(conversation_id, action.id.clone()))
{
self.deferred_provider_preprocessing
.insert((conversation_id, action.id.clone()), false);
} else {
preprocess_future.push(self.preprocess_action(action, conversation_id, ctx));
}
}
let preprocess_id = self
@@ -1987,6 +2123,8 @@ impl BlocklistAIActionModel {
reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
) {
self.deferred_provider_preprocessing
.retain(|(conv_id, _), _| *conv_id != conversation_id);
self.executor.update(ctx, |executor, ctx| {
executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx)
});
@@ -2187,12 +2325,16 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.deferred_provider_preprocessing
.retain(|(conv_id, _), _| *conv_id != conversation_id);
self.past_action_results
.retain(|(conv_id, _), _| *conv_id != conversation_id);
self.provider_tool_executions
.retain(|(conv_id, _), _| *conv_id != conversation_id);
self.denied_permissions
.retain(|(conv_id, _)| *conv_id != conversation_id);
self.pending_permissions
.retain(|(conv_id, _)| *conv_id != conversation_id);
self.pending_actions.remove(&conversation_id);
self.running_actions.remove(&conversation_id);
self.finished_action_results.remove(&conversation_id);
@@ -2306,6 +2448,10 @@ impl BlocklistAIActionModel {
let execution_ref = self
.provider_tool_executions
.remove(&(conversation_id, action_result.id.clone()));
self.deferred_provider_preprocessing
.remove(&(conversation_id, action_result.id.clone()));
self.pending_permissions
.remove(&(conversation_id, action_result.id.clone()));
let permission_denied = self
.denied_permissions
.remove(&(conversation_id, action_result.id.clone()));
+70 -8
View File
@@ -24,10 +24,11 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
pub use ask_user_question::AskUserQuestionExecutor;
@@ -52,10 +53,11 @@ use galaxy_util::file_type::is_buffer_binary;
use galaxyui::r#async::{Spawnable, SpawnableOutput};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use grep::GrepExecutor;
use instant::Instant;
#[cfg(feature = "local_fs")]
use mime_guess::from_path;
use notebooks::NotebookExecutor;
use parking_lot::FairMutex;
use parking_lot::{FairMutex, Mutex};
use read_documents::ReadDocumentsExecutor;
pub(super) use read_files::ReadFilesExecutor;
use read_mcp_resource::ReadMCPResourceExecutor;
@@ -96,7 +98,7 @@ use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::command_executor::shell_quote_arg;
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionId};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::shell::ShellType;
use crate::terminal::{ShellLaunchData, TerminalModel};
@@ -146,6 +148,34 @@ pub(super) enum RunningActionPhase {
Parallel(ParallelExecutionPolicy),
}
const GIT_REPOSITORY_CACHE_TTL: Duration = Duration::from_secs(60);
type GitRepositoryCacheEntries = HashMap<(SessionId, String), (Instant, bool)>;
#[derive(Clone, Default)]
struct GitRepositoryCache {
entries: Arc<Mutex<GitRepositoryCacheEntries>>,
}
impl GitRepositoryCache {
fn get(&self, session_id: SessionId, path: &str) -> Option<bool> {
let key = (session_id, path.to_string());
let mut entries = self.entries.lock();
let (cached_at, is_repository) = entries.get(&key).copied()?;
if cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL {
Some(is_repository)
} else {
entries.remove(&key);
None
}
}
fn insert(&self, session_id: SessionId, path: String, is_repository: bool) {
let mut entries = self.entries.lock();
entries.retain(|_, (cached_at, _)| cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL);
entries.insert((session_id, path), (Instant::now(), is_repository));
}
}
#[derive(Debug, Clone, Copy)]
struct ExecuteActionInput<'a> {
action: &'a AIAgentAction,
@@ -355,10 +385,21 @@ impl BlocklistAIActionExecutor {
let request_file_edits_executor = ctx.add_model(|ctx| {
RequestFileEditsExecutor::new(active_session.clone(), terminal_view_id, ctx)
});
let grep_executor =
ctx.add_model(|_| GrepExecutor::new(active_session.clone(), terminal_view_id));
let file_glob_executor =
ctx.add_model(|_| FileGlobExecutor::new(active_session.clone(), terminal_view_id));
let git_repository_cache = GitRepositoryCache::default();
let grep_executor = ctx.add_model(|_| {
GrepExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache.clone(),
)
});
let file_glob_executor = ctx.add_model(|_| {
FileGlobExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache,
)
});
let read_mcp_resource_executor = ctx
.add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id));
let call_mcp_tool_executor =
@@ -1550,8 +1591,15 @@ fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -
}
/// Returns true if the given path is a regular file on the session's filesystem.
/// Runs a shell command on the session so it works for both local and remote sessions.
/// Uses native metadata for compatible local sessions and a shell probe otherwise.
async fn is_file_path(path: &str, session: &Session) -> bool {
#[cfg(feature = "local_fs")]
if session.is_local() && !session.is_wsl() && !session.is_msys2() {
return async_fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_file());
}
let command = build_is_file_path_command(path, session.shell().shell_type());
session
.execute_command(&command, None, None, ExecuteCommandOptions::default())
@@ -1574,6 +1622,20 @@ async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Re
Ok(command_output.success())
}
async fn is_git_repository_cached(
absolute_path: &str,
session: &Session,
cache: &GitRepositoryCache,
) -> anyhow::Result<bool> {
if let Some(is_repository) = cache.get(session.id(), absolute_path) {
return Ok(is_repository);
}
let is_repository = is_git_repository(absolute_path, session).await?;
cache.insert(session.id(), absolute_path.to_string(), is_repository);
Ok(is_repository)
}
fn get_server_output_id(
conversation_id: AIConversationId,
ctx: &mut AppContext,
@@ -26,13 +26,14 @@ use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
use super::{
get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
get_server_output_id, is_git_repository_cached, ActionExecution, AnyActionExecution,
ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) {
@@ -41,10 +42,15 @@ fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext)
}
impl FileGlobExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -134,11 +140,18 @@ impl FileGlobExecutor {
let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input);
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
match run_file_glob(
patterns_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
@@ -204,6 +217,7 @@ async fn run_file_glob(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob"));
@@ -213,12 +227,13 @@ async fn run_file_glob(
};
let shell_type = session.shell().shell_type();
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_in_git_repo =
is_git_repository_cached(&absolute_path, session.as_ref(), &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
if is_in_git_repo {
run_git_ls_files_command(
@@ -226,6 +226,7 @@ impl GetFilesExecutor {
GetRelevantFilesError::Missing => {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
GetRelevantFilesError::LocalIndexUnavailable(message) => message,
};
ActionExecution::Sync(AIAgentActionResultType::GetFiles(
GetFilesResult::Error(error_message),
@@ -5,14 +5,14 @@ use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use futures::{FutureExt, StreamExt};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
get_server_output_id, is_file_path, is_git_repository_cached, ActionExecution,
AnyActionExecution, ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::redaction::redact_secrets;
@@ -30,6 +30,8 @@ use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
const GREP_MAX_MATCHES: usize = 1_000;
const GREP_MAX_MATCHES_PER_FILE: usize = 50;
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
/// Information about the Grep call that resulted in an error, used to send
@@ -179,13 +181,19 @@ fn log_grep_error(
pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
impl GrepExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -264,11 +272,18 @@ impl GrepExecutor {
let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id;
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data)
.with_timeout(GREP_TIMEOUT)
.await
match run_grep(
queries_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(GREP_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
@@ -345,6 +360,7 @@ async fn run_grep(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> Result<GrepResult, GrepError> {
if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string()));
@@ -373,13 +389,13 @@ async fn run_grep(
Cow::Borrowed(absolute_path.as_str())
};
// TODO(CODE-239): Cache the result of this check.
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_grep_in_git_repo =
is_git_repository_cached(&execute_directory, &session, &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`;
@@ -428,10 +444,13 @@ async fn run_grep(
#[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path);
let result = galaxy_ripgrep::search::search(queries, &[path], false, false).await;
let result = galaxy_ripgrep::search::search_streaming(queries, &[path], false, false);
match result {
Ok(matches) => {
let mut matches = matches.take(GREP_MAX_MATCHES + 1).collect::<Vec<_>>().await;
let truncated = matches.len() > GREP_MAX_MATCHES;
matches.truncate(GREP_MAX_MATCHES);
let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches {
files_map
@@ -448,7 +467,10 @@ async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepRe
matched_lines,
})
.collect();
Ok(GrepResult::Success { matched_files })
Ok(GrepResult::Success {
matched_files,
truncated,
})
}
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
}
@@ -482,7 +504,10 @@ async fn run_git_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -496,6 +521,7 @@ async fn run_git_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -531,7 +557,10 @@ async fn run_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -545,6 +574,7 @@ async fn run_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -580,7 +610,10 @@ async fn run_select_string_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(select_string_command)
@@ -595,7 +628,9 @@ async fn run_select_string_command(
fn build_git_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
// This command works on all the shells we support (even PowerShell).
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
let mut grep_command = format!(
"git --no-pager grep --color=never --untracked -nIE -m {GREP_MAX_MATCHES_PER_FILE}"
);
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -613,7 +648,8 @@ fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellTy
// * "-I" ignores binary files
// * "-H" prints file name headers
// * "-E" uses extended regex expressions
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
let mut grep_command =
format!("grep --color=never -nrIHE --devices=skip -m {GREP_MAX_MATCHES_PER_FILE}");
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -627,7 +663,7 @@ fn build_select_string_command(queries: &[String], target_path: &str) -> String
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file.
format!(
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {} | Select-Object -First {}",
shell_quote_arg(target_path, ShellType::PowerShell),
queries
.iter()
@@ -635,7 +671,8 @@ fn build_select_string_command(queries: &[String], target_path: &str) -> String
// strings, so patterns must be single-quoted data arguments.
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
.collect::<Vec<_>>()
.join(",")
.join(","),
GREP_MAX_MATCHES + 1,
)
}
@@ -648,10 +685,11 @@ fn parse_grep_output(
output: &str,
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> {
) -> anyhow::Result<(Vec<GrepFileMatch>, bool)> {
let mut matched_files = HashMap::new();
let mut lines = output.trim().lines();
for line in output.trim().split("\n") {
for line in lines.by_ref().take(GREP_MAX_MATCHES) {
let mut parts = line.split(":");
let file = parts.next();
let line_number = parts.next();
@@ -677,17 +715,24 @@ fn parse_grep_output(
.push(GrepLineMatch { line_number });
}
Ok(matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect())
let truncated = lines.next().is_some()
|| matched_files
.values()
.any(|matched_lines| matched_lines.len() >= GREP_MAX_MATCHES_PER_FILE);
Ok((
matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect(),
truncated,
))
}
impl Entity for GrepExecutor {
@@ -81,7 +81,7 @@ fn build_git_grep_command_single_quotes_shell_substitution() {
assert_eq!(
command,
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
"git --no-pager grep --color=never --untracked -nIE -m 50 -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
);
}
@@ -93,7 +93,7 @@ fn build_grep_command_escapes_single_quotes() {
assert_eq!(
command,
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
r#"grep --color=never -nrIHE --devices=skip -m 50 -e 'owner'"'"'s code' '/tmp/repo'"#
);
}
@@ -105,6 +105,23 @@ fn build_select_string_command_single_quotes_powershell_substitution() {
assert_eq!(
command,
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal''' | Select-Object -First 1001"#
);
}
#[test]
fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
let output = (1..=GREP_MAX_MATCHES_PER_FILE)
.map(|line| format!("src/lib.rs:{line}:match"))
.collect::<Vec<_>>()
.join("\n");
let (matched_files, truncated) = parse_grep_output(&output, None, None).unwrap();
assert_eq!(matched_files.len(), 1);
assert_eq!(
matched_files[0].matched_lines.len(),
GREP_MAX_MATCHES_PER_FILE
);
assert!(truncated);
}
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use futures::future::BoxFuture;
use futures::future::{join_all, BoxFuture};
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -236,14 +236,24 @@ impl ReadFilesExecutor {
// Local path.
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await?;
let reads = locations.iter().map(|location| {
read_local_file_context(
std::slice::from_ref(location),
current_working_directory.clone(),
shell.clone(),
None,
None,
)
});
let mut result = super::ReadFileContextResult {
file_contexts: Vec::new(),
missing_files: Vec::new(),
};
for file_result in join_all(reads).await {
let file_result = file_result?;
result.file_contexts.extend(file_result.file_contexts);
result.missing_files.extend(file_result.missing_files);
}
if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success {
files: result.file_contexts,
@@ -1,6 +1,8 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::LocalProjectIndexManager;
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::FutureExt;
@@ -273,6 +275,7 @@ impl SearchCodebaseExecutor {
),
},
Err(e) => {
self.active_searches.remove(id);
log::warn!("Failed to send remote get_relevant_files request: {e:?}");
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
@@ -385,6 +388,7 @@ impl SearchCodebaseExecutor {
),
},
Err(e) => {
self.active_searches.remove(id);
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
let error_message = match e {
@@ -397,6 +401,7 @@ impl SearchCodebaseExecutor {
GetRelevantFilesError::Missing => {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
GetRelevantFilesError::LocalIndexUnavailable(message) => message,
};
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
@@ -465,6 +470,13 @@ impl SearchCodebaseExecutor {
pwd
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if let Some(root) = app
.try_get_singleton_model_as_ref::<LocalProjectIndexManager>()
.and_then(|manager| manager.root_for_path(&search_dir))
{
return Some(root);
}
self.get_relevant_files_controller
.as_ref(app)
.root_directory_for_search(&search_dir, app)
@@ -16,11 +16,23 @@ use crate::test_util::settings::initialize_history_persistence_for_tests;
const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_test(0);
/// Stable placeholder run_id assigned to the parent conversation in tests
/// that exercise the server-backed Oz child path. Tests without this id
/// exercise the direct-provider local child path instead.
/// Stable server run ID for parent conversations. Child execution mode, rather
/// than the presence of this ID, selects startup versus completion waits.
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
fn remote_execution_mode() -> StartAgentExecutionMode {
StartAgentExecutionMode::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![],
model_id: String::new(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
}
}
#[derive(Default)]
struct CapturedDirectProviderChildLinks(Vec<(AIAgentActionId, AIConversationId, AIConversationId)>);
@@ -394,10 +406,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action = build_start_agent_action(StartAgentVersion::V1, remote_execution_mode());
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
@@ -581,10 +590,7 @@ fn execute_resolves_success_when_request_linkage_happens_after_child_already_sta
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action = build_start_agent_action(StartAgentVersion::V1, remote_execution_mode());
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
@@ -739,10 +745,7 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action = build_start_agent_action(StartAgentVersion::V1, remote_execution_mode());
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
@@ -1769,7 +1772,7 @@ struct PendingChildLaunch {
child_conversation_id: AIConversationId,
}
/// Dispatches a local child launch and creates (but does not yet link) its
/// Dispatches a remote child launch and creates (but does not yet link) its
/// child conversation, leaving one in-flight pending in the executor with a
/// model subscribed to capture `CleanupFailedChildLaunch` events. Tests link
/// the child and drive it to a terminal state, then assert on cleanup. The
@@ -1803,10 +1806,7 @@ fn dispatch_pending_child_launch(
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action = build_start_agent_action(StartAgentVersion::V1, remote_execution_mode());
// The pending lives in the executor regardless of the returned execution,
// and cleanup is emitted synchronously from the child status update, so the
// action-result plumbing is discarded.
@@ -19,6 +19,7 @@ use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStream
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::server::server_api::ai::{AIClient, MockAIClient};
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_history_persistence_for_tests;
#[test]
fn watchdog_timeout_constants_match_documented_values() {
@@ -88,24 +89,12 @@ fn execute_invokes_parent_registration_and_honors_child_short_circuit() {
// without a server fetch (asserted via the mock's times(0) expectation),
// and the wait still flips the conversation into WaitingForEvents.
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let _flag_guard = FeatureFlag::WaitForEventsParentRegistration.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
// A streamer whose server fetch must never be called: the child
// short-circuit precedes any `get_ambient_agent_task` call.
let mut mock = MockAIClient::new();
mock.expect_get_ambient_agent_task().times(0);
let ai_client: Arc<dyn AIClient> = Arc::new(mock);
let server_api = ServerApiProvider::new_for_test().get();
// Held for the lifetime of the test so the mock's times(0) expectation
// is verified on drop; resolved internally by `execute()` via
// `OrchestrationEventStreamer::handle`.
let _streamer = app.add_singleton_model(|ctx| {
OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx)
});
let executor = app.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
// Child conversation: own run_id plus a parent_agent_id.
@@ -123,6 +112,21 @@ fn execute_invokes_parent_registration_and_honors_child_short_circuit() {
);
});
// Subscribe after restoring the fixture so unrelated restore-time harness
// discovery does not count as a parent-registration fetch.
// A streamer whose server fetch must never be called: the child
// short-circuit precedes any `get_ambient_agent_task` call.
let mut mock = MockAIClient::new();
mock.expect_get_ambient_agent_task().times(0);
let ai_client: Arc<dyn AIClient> = Arc::new(mock);
let server_api = ServerApiProvider::new_for_test().get();
// Held for the lifetime of the test so the mock's times(0) expectation
// is verified on drop; resolved internally by `execute()` via
// `OrchestrationEventStreamer::handle`.
let _streamer = app.add_singleton_model(|ctx| {
OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx)
});
let action = AIAgentAction {
id: AIAgentActionId::from("wait-action".to_string()),
action: AIAgentActionType::WaitForEvents {
+102
View File
@@ -7,6 +7,7 @@ use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
};
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
@@ -54,6 +55,75 @@ fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
}
}
#[test]
fn readiness_retry_cannot_preprocess_across_a_serial_barrier_or_skip_a_pending_tool() {
warpui::App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let terminal_view_id = ctx.view_id();
let relevant_files = ctx.add_model(GetRelevantFilesController::new);
let action_model = ctx.add_model(|ctx| {
BlocklistAIActionModel::new(
view.model.clone(),
view.active_session().clone(),
view.model_event_dispatcher(),
relevant_files,
terminal_view_id,
ctx,
)
});
action_model.update(ctx, |model, ctx| {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
model.provider_tool_executions.extend(
provider_action_correlations(&actions, conversation_id, &batch).unwrap(),
);
for action in &actions {
model
.deferred_provider_preprocessing
.insert((conversation_id, action.id.clone()), false);
}
model
.pending_actions
.insert(conversation_id, actions.clone().into());
model.add_running_action(
conversation_id,
AIAgentActionId::from("running".to_string()),
RunningActionPhase::Serial,
);
assert!(model
.start_pending_action_by_id(
&actions[0].id,
conversation_id,
ActionExecutionInitiator::Automatic,
ctx
)
.is_none());
assert!(
!model.deferred_provider_preprocessing
[&(conversation_id, actions[0].id.clone())]
);
model.running_actions.remove(&conversation_id);
assert!(model
.start_pending_action_by_id(
&actions[1].id,
conversation_id,
ActionExecutionInitiator::User,
ctx
)
.is_none());
assert!(
!model.deferred_provider_preprocessing
[&(conversation_id, actions[1].id.clone())]
);
assert_eq!(model.pending_actions[&conversation_id].len(), 2);
});
});
});
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
@@ -104,6 +174,24 @@ fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
);
}
#[test]
fn exact_provider_action_correlation_is_idempotent() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first"]);
let actions = vec![action("first")];
let execution_ref = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "first");
let existing = HashMap::from([((conversation_id, actions[0].id.clone()), execution_ref)]);
assert!(BlocklistAIActionModel::provider_action_ids_to_enqueue_from(
&existing,
&actions,
conversation_id,
&batch,
)
.unwrap()
.is_empty());
}
#[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase =
@@ -123,6 +211,20 @@ fn parallel_phase_only_admits_matching_autoexecutable_actions() {
));
}
#[test]
fn parallel_user_started_action_allows_compatible_automatic_followups() {
let parallel = StartedAction::Async {
phase: RunningActionPhase::Parallel(execute::ParallelExecutionPolicy::ReadOnlyLocalContext),
};
let serial = StartedAction::Async {
phase: RunningActionPhase::Serial,
};
assert!(StartedAction::Sync.allows_compatible_following_actions());
assert!(parallel.allows_compatible_following_actions());
assert!(!serial.allows_compatible_following_actions());
}
#[test]
fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
let read_only_phase =
+83 -28
View File
@@ -28,7 +28,7 @@ use std::sync::Arc;
use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest};
use base64::Engine as _;
use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent};
use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState};
use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag;
@@ -1445,18 +1445,38 @@ impl AIBlock {
initial_requested_command_action_id: Some(initial_requested_command_action_id),
..
} => {
me.collapse_requested_command_view(initial_requested_command_action_id, ctx);
let restore_completed_takeover = {
let terminal_model = me.terminal_model.lock();
terminal_model
.block_list()
.block_for_ai_action_id(initial_requested_command_action_id)
.is_some_and(|block| {
block.finished()
&& block.long_running_control_state().is_some_and(
LongRunningCommandControlState::is_user_in_control,
)
})
};
if restore_completed_takeover {
me.expand_requested_command_view(initial_requested_command_action_id, ctx);
} else {
me.collapse_requested_command_view(
initial_requested_command_action_id,
ctx,
);
}
}
CLISubagentEvent::UpdatedControl {
requested_command_action_id: Some(requested_command_action_id),
agent_has_control,
..
} => {
if let Some(requested_command_view) =
me.requested_commands.get(requested_command_action_id)
{
requested_command_view
.view
.update(ctx, |_, ctx| ctx.notify());
if *agent_has_control {
me.expand_requested_command_view(requested_command_action_id, ctx);
} else {
me.requested_commands_to_auto_collapse
.remove(requested_command_action_id);
me.collapse_requested_command_view(requested_command_action_id, ctx);
}
}
_ => {}
@@ -1465,18 +1485,36 @@ impl AIBlock {
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock();
if terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
.and_then(|block| block.agent_interaction_metadata())
.is_some_and(|metadata| {
let (requested_action_id, should_restore_takeover) = {
let mut terminal_model = me.terminal_model.lock();
let command_metadata = terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
.and_then(|block| block.agent_interaction_metadata());
let action_id = command_metadata
.and_then(|metadata| metadata.requested_command_action_id().cloned())
.filter(|id| me.requested_action_ids.contains(id));
let should_restore = command_metadata.is_some_and(|metadata| {
metadata
.requested_command_action_id()
.is_some_and(|id| me.requested_action_ids.contains(id))
})
{
ctx.notify();
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_user_in_control)
});
if should_restore {
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, false);
}
}
(action_id, should_restore)
};
if let Some(action_id) = requested_action_id {
if should_restore_takeover {
me.requested_commands_to_auto_collapse.remove(&action_id);
me.expand_requested_command_view(&action_id, ctx);
} else {
ctx.notify();
}
}
}
});
@@ -3702,12 +3740,17 @@ impl AIBlock {
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = {
let (has_finished_command_block, user_controls_command) = {
let terminal_model = self.terminal_model.lock();
terminal_model
let command_block = terminal_model
.block_list()
.block_for_ai_action_id(action_id)
.is_some_and(|block| block.finished())
.block_for_ai_action_id(action_id);
(
command_block.is_some_and(|block| block.finished()),
command_block
.and_then(|block| block.long_running_control_state())
.is_some_and(LongRunningCommandControlState::is_user_in_control),
)
};
if !has_finished_command_block
&& !action_status.is_some_and(|a| a.is_running() || a.is_done())
@@ -3727,12 +3770,11 @@ impl AIBlock {
self.requested_commands_to_auto_collapse.remove(action_id);
}
// Requested-command output is rendered by the inline pane whenever it is
// expanded. Keep the backing terminal block hidden in both states so collapsing
// the pane does not move the output to the bottom of the conversation.
// Agent-owned command output stays contained in the inline pane. During user
// takeover, the same live backing terminal block becomes the interactive surface.
ctx.emit(AIBlockEvent::UpdateInlineActionVisibility {
action_id: action_id.clone(),
is_visible: false,
is_visible: user_controls_command && !has_finished_command_block,
});
ctx.notify();
}
@@ -4348,7 +4390,9 @@ impl AIBlock {
// If auto-login is enabled, run the login command automatically
if auto_login_enabled {
ctx.emit(AIBlockEvent::RunAwsLoginCommand);
// Try the SDK chain first. This handles credentials that were refreshed by
// another AWS process without unnecessarily launching an interactive SSO flow.
ctx.emit(AIBlockEvent::RefreshAwsCredentials);
}
let model_name = model_name.clone();
@@ -6105,6 +6149,15 @@ impl AIBlock {
self.requested_commands.iter()
}
pub(crate) fn requested_command_view(
&self,
action_id: &AIAgentActionId,
) -> Option<ViewHandle<RequestedCommandView>> {
self.requested_commands
.get(action_id)
.map(|command| command.view.clone())
}
/// Collects all imported review comments stored in this block
/// and the base branch (if any). Returns `None` when this block has no imported comments.
pub(crate) fn collect_imported_comments(&self) -> Option<ImportedBlockComments> {
@@ -6447,6 +6500,8 @@ pub enum AIBlockEvent {
OpenActiveAgentProfileEditor,
/// Run the configured AWS auth refresh command to fix expired Bedrock credentials
RunAwsLoginCommand,
/// Reload credentials and fall back to the configured auth command if needed.
RefreshAwsCredentials,
/// Emitted when a passive code diff has loaded its diffs and is ready to display.
/// This is used to trigger height recalculation since the diffs are loaded asynchronously
/// after the initial output completes.
+12 -83
View File
@@ -711,6 +711,11 @@ impl CLISubagentController {
let action_id = active_block.requested_command_action_id().cloned();
let conversation_id = active_block.ai_conversation_id();
let agent_has_control = active_block.is_agent_in_control();
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, true);
}
// Conversation cancellation potentially takes a lock on terminal model if the
// cancelled action is a shell command action, so we have to drop the terminal
// model lock before actually cancelling the conversation.
@@ -775,6 +780,11 @@ impl CLISubagentController {
);
let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control();
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, false);
}
drop(terminal_model);
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
@@ -1148,86 +1158,5 @@ fn matches_requested_command_identity(
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
}
#[path = "cli_controller_tests.rs"]
mod tests;
@@ -0,0 +1,81 @@
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
@@ -15,7 +15,7 @@ use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
const SPEEDBUMP_HEADER: &str = "Index Codebase?";
const SPEEDBUMP_TEXT: &str = "Indexing helps agents quickly understand context and provide targeted solutions. Code is never stored on the server.";
const SPEEDBUMP_TEXT: &str = "Galaxy builds and searches this project index on your device. Source files are read from disk when results are returned to the agent.";
/// Uniform padding around the banner
const PADDING: f32 = 12.;
/// Text for the button that allows execution
+57 -6
View File
@@ -5895,6 +5895,7 @@ impl BlocklistAIController {
return;
}
};
coordinator.set_max_context_tokens(response_config.max_context_tokens);
if let Some(reason) = cancellation_reason {
if !coordinator.run().is_terminal() {
if let Err(error) = coordinator.run_mut().cancel(reason.to_string()) {
@@ -6368,6 +6369,7 @@ impl BlocklistAIController {
return;
}
};
coordinator.set_max_context_tokens(response_config.max_context_tokens);
if let Some(profile) = cli_monitor_profile {
if let Err(error) = coordinator.insert_profile(
CLI_MONITOR_PROVIDER_PROFILE,
@@ -6961,7 +6963,7 @@ impl BlocklistAIController {
ctx: &mut ModelContext<Self>,
) {
let usage_u32 = |tokens: u64| u32::try_from(tokens).unwrap_or(u32::MAX);
let cost_cents = crate::ai::bedrock::response_translator::estimate_cost_cents(
let cost_cents = crate::ai::provider::response_translator::estimate_cost_cents(
usage_u32(usage.input_tokens),
usage_u32(usage.output_tokens),
usage_u32(usage.cached_input_tokens),
@@ -6997,6 +6999,13 @@ impl BlocklistAIController {
return;
}
self.begin_active_provider_progressive_summary(conversation_id, ctx);
if matches!(
self.active_provider_progressive_summaries
.get(&conversation_id),
Some(ActiveProviderProgressiveSummaryState::InFlight { .. })
) {
return;
}
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
return;
};
@@ -7452,6 +7461,24 @@ impl BlocklistAIController {
}
};
match block {
ProviderRunBlock::ContextWindowExceeded => {
slot.run = Some(run);
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
self.fail_active_provider_run(
conversation_id,
format!("failed to persist context compaction boundary: {error}"),
ctx,
);
return;
}
if !self.begin_active_provider_progressive_summary(conversation_id, ctx) {
self.fail_active_provider_run(
conversation_id,
"provider context overflow could not start compaction".to_string(),
ctx,
);
}
}
ProviderRunBlock::ReadyToCallModel => {
slot.run = Some(run);
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
@@ -7539,7 +7566,7 @@ impl BlocklistAIController {
else {
return;
};
let (converted_actions, invalid_results) =
let (mut converted_actions, invalid_results) =
convert_provider_tool_batch(&run.action_context, &batch);
for result in &invalid_results {
if let Err(error) = run
@@ -7586,6 +7613,30 @@ impl BlocklistAIController {
};
}
}
let action_ids_to_enqueue = match self.action_model.update(ctx, |action_model, _| {
action_model.provider_action_ids_to_enqueue(
&converted_actions
.iter()
.map(|(action, _)| action.clone())
.collect::<Vec<_>>(),
conversation_id,
&executable_batch,
)
}) {
Ok(action_ids) => action_ids,
Err(error) => {
self.fail_active_provider_run(conversation_id, error.to_string(), ctx);
return;
}
};
converted_actions.retain(|(action, _)| action_ids_to_enqueue.contains(&action.id));
let queued_call_ids = converted_actions
.iter()
.map(|(action, _)| action.id.to_string())
.collect::<HashSet<_>>();
executable_batch
.calls
.retain(|call| queued_call_ids.contains(&call.call.id));
let stream_id = self.active_provider_runs[&conversation_id]
.stream_id
.clone();
@@ -9522,7 +9573,7 @@ impl BlocklistAIController {
}
Some(warp_multi_agent_api::response_event::stream_finished::Reason::ContextWindowExceeded(_)) => {
let error_message = "Input exceeded context window limit.";
crate::ai::bedrock::crash_log::log_crash(
crate::ai::provider::crash_log::log_crash(
"ContextWindowExceeded",
error_message,
"unknown",
@@ -9615,7 +9666,7 @@ impl BlocklistAIController {
let error_message = format!(
"Response stream finished unexpectedly with internal error: {message}",
);
crate::ai::bedrock::crash_log::log_crash(
crate::ai::provider::crash_log::log_crash(
"InternalError",
&error_message,
"unknown",
@@ -9640,7 +9691,7 @@ impl BlocklistAIController {
}
Some(warp_multi_agent_api::response_event::stream_finished::Reason::MaxTokenLimit(_)) => {
let error_message = "Input exceeded context window limit.";
crate::ai::bedrock::crash_log::log_crash(
crate::ai::provider::crash_log::log_crash(
"MaxTokenLimit",
error_message,
"unknown",
@@ -9940,7 +9991,7 @@ impl BlocklistAIController {
u32::try_from(tokens).unwrap_or(u32::MAX)
};
let cost_cents =
crate::ai::bedrock::response_translator::estimate_cost_cents(
crate::ai::provider::response_translator::estimate_cost_cents(
usage_u32(usage.input_tokens),
usage_u32(usage.output_tokens),
usage_u32(usage.cached_input_tokens),
@@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::LocalProjectIndexManager;
use chrono::Local;
use galaxy_core::features::FeatureFlag;
use galaxy_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
@@ -65,10 +66,12 @@ pub(super) fn input_context_for_request(
context.push(AIAgentContext::ExecutionEnvironment(env));
}
if FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& FeatureFlag::CrossRepoContext.is_enabled()
{
let session_context = SessionContext::from_session(active_session, app);
let session_context = SessionContext::from_session(active_session, app);
let should_add_codebase_context = FeatureFlag::CrossRepoContext.is_enabled()
&& ((session_context.is_remote() && FeatureFlag::FullSourceCodeEmbedding.is_enabled())
|| (!session_context.is_remote()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))));
if should_add_codebase_context {
if session_context.is_remote() {
add_remote_codebase_context(&mut context, &session_context, app);
} else {
@@ -91,27 +94,28 @@ pub(super) fn input_context_for_request(
context.into()
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn add_local_codebase_context(context: &mut Vec<AIAgentContext>, app: &AppContext) {
for (codebase_path, status) in
CodebaseIndexManager::as_ref(app).get_codebase_index_statuses(app)
{
// TODO(daniel): We should figure out a mechanism for handling stale codebases.
if status.has_synced_version() {
// For now, we pass the name of the directory as the name of the
// codebase.
for (codebase_path, status) in LocalProjectIndexManager::as_ref(app).statuses() {
if matches!(
status,
ai::index::local_project_index::LocalIndexStatus::Ready { .. }
) {
let codebase_name = codebase_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
context.push(AIAgentContext::Codebase {
name: codebase_name.into(),
path: codebase_path.to_string_lossy().into(),
})
});
}
}
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn add_local_codebase_context(_context: &mut Vec<AIAgentContext>, _app: &AppContext) {}
#[cfg(not(target_family = "wasm"))]
fn add_remote_codebase_context(
context: &mut Vec<AIAgentContext>,
@@ -32,11 +32,11 @@ use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))]
use crate::ai::agent::AIAgentInput;
use crate::ai::agent::{AIIdentifiers, CancellationReason};
use crate::ai::bedrock::client::BedrockClientConfig;
#[cfg(not(target_family = "wasm"))]
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::provider::client::BedrockClientConfig;
use crate::ai::provider::ProviderConfig;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
@@ -181,7 +181,6 @@ impl ResponseStream {
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: client_config.max_output_tokens,
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
@@ -191,12 +190,6 @@ impl ResponseStream {
let auth_method = *settings.bedrock_auth_method.value();
let region = settings.bedrock_region.value().clone();
let cross_region_inference = *settings.bedrock_cross_region_inference.value();
let use_rig = crate::ai::bedrock::models::configured_model_uses_rig(
model_id,
settings.bedrock_models.value(),
&region,
cross_region_inference,
);
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = BedrockClientConfig {
auth_method,
@@ -206,7 +199,6 @@ impl ResponseStream {
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference,
use_rig,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
@@ -236,7 +228,7 @@ impl ResponseStream {
format!("bedrock:{:?}:region={region}", config.auth_method)
}
ProviderConfig::OpenAI(config) => {
format!("openai:{:?}:rig={}", config.kind, config.use_rig)
format!("openai:{:?}", config.kind)
}
ProviderConfig::None => "none".to_string(),
}
@@ -17,7 +17,6 @@ use galaxyui::elements::{
};
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _;
use galaxyui::units::IntoPixels;
use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, UpdateView, View, ViewContext, ViewHandle,
@@ -46,7 +45,6 @@ use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::requested_action::render_requested_action_body_text;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
@@ -57,9 +55,13 @@ use crate::cmd_or_ctrl_shift;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::editor::InteractionState;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
use crate::settings::InputModeSettings;
use crate::settings::{FontSettings, InputModeSettings};
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::blockgrid_element::TerminalGridSnapshotElement;
use crate::terminal::ligature_settings::should_use_ligature_rendering;
use crate::terminal::model::block::Block;
use crate::terminal::model::grid::Dimensions;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::json_tree::{JsonTreeState, PathSegment};
@@ -335,8 +337,6 @@ pub struct RequestedCommandView {
header_mouse_state: MouseStateHandle,
output_scroll_state: ClippedScrollStateHandle,
follow_command_output: Cell<bool>,
last_command_output_len: Cell<usize>,
last_output_scroll_start: Cell<f32>,
is_editing: bool,
// A requested command can either be copied directly off of one citation (such as a Warp Drive
@@ -597,8 +597,6 @@ impl RequestedCommandView {
header_mouse_state: Default::default(),
output_scroll_state: ClippedScrollStateHandle::new(),
follow_command_output: Cell::new(true),
last_command_output_len: Cell::new(0),
last_output_scroll_start: Cell::new(0.),
copied_from_citation: None,
derived_from_citations: Default::default(),
citation_state_handles: Default::default(),
@@ -714,9 +712,7 @@ impl RequestedCommandView {
self.is_header_expanded = value;
if value && self.action_type.is_requested_command() {
self.follow_command_output.set(true);
self.last_command_output_len.set(0);
self.last_output_scroll_start
.set(self.output_scroll_state.scroll_start().as_f32());
self.output_scroll_state.set_follow_end(true);
}
if is_user_initiated {
self.is_user_expanded = value;
@@ -781,6 +777,21 @@ impl RequestedCommandView {
self.is_header_expanded
}
fn is_user_controlling_command(&self) -> bool {
self.action_type.is_requested_command()
&& self
.terminal_model
.lock()
.block_list()
.block_for_ai_action_id(&self.action_id)
.is_some_and(|block| {
block.is_executing()
&& block
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_user_in_control)
})
}
/// We use the requested command footer to show citations.
fn maybe_render_footer(&self, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
@@ -1548,26 +1559,6 @@ impl View for RequestedCommandView {
let is_input_pinned_to_top =
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
// Tracks whether the expanded command has a terminal output block directly below it.
// Used to remove the bottom margin for visual continuity with the terminal block.
let is_rendered_above_expanded_command_block = {
let terminal_model = self.terminal_model.lock();
is_last_output_message_in_output
&& self.action_type.is_requested_command()
&& action_status.as_ref().is_some_and(|status| {
status.is_running() || (status.is_success() || status.is_failed())
})
&& !is_input_pinned_to_top
&& self.is_header_expanded
&& terminal_model
.block_list()
.is_requested_command_block_immediately_after_ai_block(
self.ai_block_view_id,
&self.action_id,
)
};
// When the requested command is expanded but there is no subsequent block containing
// command details beneath, then the command details must be rendered inline.
let should_render_editor = self.is_header_expanded
@@ -1583,47 +1574,56 @@ impl View for RequestedCommandView {
&& self.action_type.is_mcp_tool()
&& !self.command_text.is_empty();
// Requested command blocks are hidden terminal blocks. Keep their output in this pane
// instead of revealing the terminal block below the pane when the header is expanded.
let command_output = if self.is_header_expanded && self.action_type.is_requested_command() {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_for_ai_action_id(&self.action_id)
.map(|block| {
let (command, output) = block.command_and_output_with_secret_obfuscated(false);
if output.is_empty() {
command
// Requested command blocks stay hidden terminal blocks. Snapshot their real terminal grid
// so ANSI styling, cursor state, alternate-screen TUIs, and images render inside this pane.
let command_output_grid =
if self.is_header_expanded && self.action_type.is_requested_command() {
let terminal_model = self.terminal_model.lock();
let requested_block = terminal_model
.block_list()
.block_for_ai_action_id(&self.action_id);
let active_block_owns_alt_screen = terminal_model.is_alt_screen_active()
&& terminal_model
.block_list()
.active_block()
.requested_command_action_id()
.is_some_and(|action_id| action_id == &self.action_id);
requested_block.and_then(|block| {
let (grid, row_count) = if active_block_owns_alt_screen {
let grid = terminal_model.alt_screen().grid_handler();
(grid.clone(), grid.visible_rows())
} else {
format!("{command}\n\n{output}")
}
let output_grid = block.output_grid();
(
output_grid.grid_handler().clone(),
output_grid.len_displayed(),
)
};
(row_count > 0).then_some((
grid,
row_count,
terminal_model.colors(),
terminal_model.override_colors(),
terminal_model.image_id_to_metadata.clone(),
))
})
.filter(|output| !output.is_empty())
} else {
None
};
} else {
None
};
if let Some(output) = command_output.as_ref() {
let scroll_start = self.output_scroll_state.scroll_start().as_f32();
let last_scroll_start = self.last_output_scroll_start.get();
// Keep following live command output until the user deliberately scrolls upward.
// Collapsing and reopening the pane resumes following from the bottom.
if self.follow_command_output.get() && scroll_start + 0.5 < last_scroll_start {
if command_output_grid.is_some() {
let is_at_bottom = self.output_scroll_state.is_scrolled_to_end();
if self.follow_command_output.get() && !is_at_bottom {
self.follow_command_output.set(false);
} else if !self.follow_command_output.get() && is_at_bottom {
self.follow_command_output.set(true);
}
if self.follow_command_output.get()
&& output.len() != self.last_command_output_len.get()
{
// Clipped scrollables clamp this sentinel to their actual maximum during layout.
self.output_scroll_state.scroll_to(f32::MAX.into_pixels());
}
self.last_command_output_len.set(output.len());
self.last_output_scroll_start.set(scroll_start);
self.output_scroll_state
.set_follow_end(self.follow_command_output.get());
}
let should_render_command_output = command_output.is_some();
let should_render_command_output = command_output_grid.is_some();
let has_citations_footer =
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
@@ -1713,30 +1713,28 @@ impl View for RequestedCommandView {
);
}
if let Some(output) = command_output {
let output_text = render_requested_action_body_text(
output.as_str().into(),
appearance.monospace_font_family(),
if let Some((grid, row_count, colors, override_colors, image_metadata)) =
command_output_grid
{
let mut output_grid = TerminalGridSnapshotElement::new(
&grid,
row_count,
colors,
override_colors,
image_metadata,
appearance,
*FontSettings::as_ref(app).enforce_minimum_contrast,
get_secret_obfuscation_mode(app),
self.ai_block_view_id,
app,
)
.finish();
let content_selected_text = self.content_selected_text.clone();
let selectable_output = SelectableArea::new(
self.content_selection_handle.clone(),
#[allow(clippy::unwrap_used)]
move |selection_args, _, _| {
*content_selected_text.write().unwrap() = selection_args.selection;
},
output_text,
)
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(RequestedCommandViewAction::SelectText);
})
.finish();
);
if should_use_ligature_rendering(app) {
output_grid = output_grid.with_ligature_rendering();
}
let scrollable = NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.output_scroll_state.clone(),
child: selectable_output,
child: output_grid.finish(),
},
Fill::None,
Fill::None,
@@ -1765,18 +1763,17 @@ impl View for RequestedCommandView {
.as_ref()
.is_some_and(|status| status.is_blocked());
// If the requested command is expanded above a terminal block or
// the next exchange flows directly after, remove bottom margin for
// visual continuity.
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
&& is_last_output_message_in_output
&& (BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.client_ids.conversation_id)
.is_some_and(|conversation| {
// Prevents an issue where the bottom margin is removed when the requested command is the last message and we cancel it.
// We want to keep the margin in this case so that there's visual separation between the cancelled command and footer.
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange flows directly after, remove bottom margin for visual continuity.
// The backing terminal command block remains hidden and does not participate in spacing.
let should_remove_bottom_margin = (self.action_type.is_requested_command()
|| self.action_type.is_mcp_tool())
&& is_last_output_message_in_output
&& (BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.client_ids.conversation_id)
.is_some_and(|conversation| {
// Prevents an issue where the bottom margin is removed when the requested command is the last message and we cancel it.
// We want to keep the margin in this case so that there's visual separation between the cancelled command and footer.
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange doesn't contain a user query, don't render bottom margin for continuity.
&& conversation
.root_task_exchanges()
@@ -1790,8 +1787,8 @@ impl View for RequestedCommandView {
.iter()
.any(|input| input.display_query().is_some())
})
}))
&& !is_input_pinned_to_top);
}))
&& !is_input_pinned_to_top;
let container = render_tool_pane_shell(
content.finish(),
@@ -1872,7 +1869,9 @@ impl TypedActionView for RequestedCommandView {
}
}
RequestedCommandViewAction::ToggleExpanded => {
self.set_is_header_expanded(!self.is_header_expanded, true, ctx)
if !self.is_user_controlling_command() {
self.set_is_header_expanded(!self.is_header_expanded, true, ctx);
}
}
RequestedCommandViewAction::OpenActiveAgentProfileEditor => {
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)
@@ -3,7 +3,7 @@ use galaxyui::elements::{
};
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use crate::ai::provider::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
+10 -3
View File
@@ -47,9 +47,16 @@ fn codebase_indexing_enabled(
surface: CodebaseAutoIndexingSurface,
codebase_context_enabled: bool,
) -> bool {
FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& surface.required_feature_enabled()
&& codebase_context_enabled
match surface {
// Native local indexing is structural/lexical and does not depend on the remote
// embedding feature. Remote indexing retains its existing feature gates.
CodebaseAutoIndexingSurface::Local => codebase_context_enabled,
CodebaseAutoIndexingSurface::Remote => {
FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& surface.required_feature_enabled()
&& codebase_context_enabled
}
}
}
pub(crate) fn codebase_auto_indexing_enabled(
+3 -3
View File
@@ -1,17 +1,17 @@
use super::*;
#[test]
fn local_auto_indexing_requires_full_source_code_embedding_codebase_context_and_auto_indexing() {
fn local_auto_indexing_requires_codebase_context_and_auto_indexing() {
{
let _flag = FeatureFlag::FullSourceCodeEmbedding.override_enabled(false);
assert!(!codebase_auto_indexing_enabled(
assert!(codebase_auto_indexing_enabled(
CodebaseAutoIndexingSurface::Local,
true,
true,
));
}
{
let _flag = FeatureFlag::FullSourceCodeEmbedding.override_enabled(true);
let _flag = FeatureFlag::FullSourceCodeEmbedding.override_enabled(false);
assert!(codebase_auto_indexing_enabled(
CodebaseAutoIndexingSurface::Local,
true,
+2 -28
View File
@@ -54,31 +54,5 @@ pub fn is_approved(response: &str) -> bool {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_approved_exact() {
assert!(is_approved("LGTM!"));
}
#[test]
fn test_is_approved_with_whitespace() {
assert!(is_approved(" LGTM! "));
assert!(is_approved("\nLGTM!\n"));
}
#[test]
fn test_is_approved_case_insensitive() {
assert!(is_approved("lgtm!"));
assert!(is_approved("Lgtm!"));
assert!(is_approved("lgtm"));
}
#[test]
fn test_is_not_approved_with_feedback() {
assert!(!is_approved("LGTM! But also fix the typo."));
assert!(!is_approved("1. Fix the loop\n2. Rename variable"));
assert!(!is_approved(""));
}
}
#[path = "prompt_tests.rs"]
mod tests;
+26
View File
@@ -0,0 +1,26 @@
use super::*;
#[test]
fn test_is_approved_exact() {
assert!(is_approved("LGTM!"));
}
#[test]
fn test_is_approved_with_whitespace() {
assert!(is_approved(" LGTM! "));
assert!(is_approved("\nLGTM!\n"));
}
#[test]
fn test_is_approved_case_insensitive() {
assert!(is_approved("lgtm!"));
assert!(is_approved("Lgtm!"));
assert!(is_approved("lgtm"));
}
#[test]
fn test_is_not_approved_with_feedback() {
assert!(!is_approved("LGTM! But also fix the typo."));
assert!(!is_approved("1. Fix the loop\n2. Rename variable"));
assert!(!is_approved(""));
}
+3 -5
View File
@@ -175,7 +175,6 @@ impl CrosscheckReviewer {
reasoning_effort: client_config.reasoning_effort.clone(),
max_input_tokens: client_config.max_input_tokens,
max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
use_rig: client_config.use_rig,
supports_system_messages: client_config.supports_system_messages,
});
}
@@ -184,7 +183,7 @@ impl CrosscheckReviewer {
if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = crate::ai::bedrock::client::BedrockClientConfig {
let mut config = crate::ai::provider::client::BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
@@ -192,7 +191,6 @@ impl CrosscheckReviewer {
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
use_rig: false,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
@@ -339,9 +337,9 @@ impl CrosscheckReviewer {
async fn invoke_via_bedrock(
agent_output: String,
model_id: String,
config: crate::ai::bedrock::client::BedrockClientConfig,
config: crate::ai::provider::client::BedrockClientConfig,
) -> Result<String, String> {
use crate::ai::bedrock::client::BedrockClient;
use crate::ai::provider::client::BedrockClient;
use crate::ai::provider::types::{ConversationMessage, MessageContent, MessageRole};
let cross_region_inference = config.cross_region_inference;
+264 -77
View File
@@ -2,25 +2,38 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use ai::index::full_source_code_embedding::RetrievalID;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::LocalProjectIndexManager;
use ai::index::locations::CodeContextLocation;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use anyhow::anyhow;
use futures_util::stream::AbortHandle;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use instant::Instant;
#[cfg(not(target_family = "wasm"))]
use crate::ai::agent::SearchCodebaseFailureReason;
use crate::ai::agent::{AIAgentActionId, SearchCodebaseResult};
use crate::ai::blocklist::SessionContext;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use crate::ai::get_relevant_files::api::{FileContext as FileContextRequest, GetRelevantFiles};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::ai::get_relevant_files::controller::GetRelevantFilesError::LocalIndexUnavailable;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use crate::ai::outline::{OutlineStatus, RepoOutlines};
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use crate::report_error;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use crate::server::server_api::{AIApiError, ServerApiProvider};
use crate::{report_error, send_telemetry_from_ctx, TelemetryEvent};
use crate::{send_telemetry_from_ctx, TelemetryEvent};
#[cfg_attr(not(target_family = "wasm"), path = "remote_search/native.rs")]
#[cfg_attr(target_family = "wasm", path = "remote_search/wasm.rs")]
mod remote_search;
@@ -60,6 +73,20 @@ pub enum GetRelevantFilesRequestTarget {
requested_codebase_path: Option<String>,
},
}
/// Lifecycle state for the two-stage `get_files` relevant-file query.
///
/// The first stage discovers paths through `GetRelevantFilesController`; the second stage reads
/// current file contents in `GetFilesExecutor`. Keeping this state separate from the controller's
/// cancellation handles preserves the existing action protocol for both local and legacy search.
#[derive(Debug, Clone)]
pub enum GetRelevantFilesStatus {
Pending { root_repo_path: PathBuf },
InFlight { root_repo_path: PathBuf },
Success { file_paths: Vec<PathBuf> },
Failed { message: String },
}
#[derive(Debug, thiserror::Error)]
pub enum GetRelevantFilesError {
#[error("Repo outline is still being computed.")]
@@ -68,15 +95,18 @@ pub enum GetRelevantFilesError {
CreateFailed,
#[error("Failed to create outline.")]
Missing,
#[error("Local project index is unavailable: {0}")]
LocalIndexUnavailable(String),
}
/// This enum allows us to use both the existing structure for outline-based indexing
/// and the new full source code indexing manager/model.
enum RequestHandle {
/// Used with outline-based indexing.
/// Used with outline-based indexing and remote search.
AbortHandle(AbortHandle),
/// Used with full source code indexing.
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
RetrievalID {
repo_path: PathBuf,
retrieval_id: RetrievalID,
@@ -88,6 +118,7 @@ impl RequestHandle {
fn abort(&mut self, ctx: &mut AppContext) {
match self {
RequestHandle::AbortHandle(abort_handle) => abort_handle.abort(),
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
RequestHandle::RetrievalID {
repo_path,
retrieval_id,
@@ -112,15 +143,63 @@ pub struct GetRelevantFilesController {
/// This allows several SearchCodebase actions to be active at once without newer requests
/// cancelling unrelated older ones.
pending_requests: std::collections::HashMap<AIAgentActionId, RequestHandle>,
/// Status for the two-stage `get_files` relevant-file query protocol.
relevant_file_statuses: std::collections::HashMap<AIAgentActionId, GetRelevantFilesStatus>,
}
impl GetRelevantFilesController {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let codebase_manager = CodebaseIndexManager::handle(ctx);
ctx.subscribe_to_model(&codebase_manager, Self::handle_codebase_manager_event);
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
let codebase_manager = CodebaseIndexManager::handle(ctx);
ctx.subscribe_to_model(&codebase_manager, Self::handle_codebase_manager_event);
}
Self::default()
}
fn root_for_status(&self, directory: &Path, app: &AppContext) -> PathBuf {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
app.try_get_singleton_model_as_ref::<LocalProjectIndexManager>()
.and_then(|manager| manager.root_for_path(directory))
.unwrap_or_else(|| directory.to_path_buf())
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
self.root_directory_for_search(directory, app)
.unwrap_or_else(|| directory.to_path_buf())
}
}
/// Queues the first stage of a relevant-file query for `GetFilesExecutor`.
pub fn queue_request(
&mut self,
action_id: AIAgentActionId,
directory: &Path,
app: &AppContext,
) {
let root_repo_path = self.root_for_status(directory, app);
self.relevant_file_statuses.insert(
action_id,
GetRelevantFilesStatus::Pending { root_repo_path },
);
}
/// Returns the current status of a relevant-file query.
pub fn status(&self, action_id: &AIAgentActionId) -> Option<&GetRelevantFilesStatus> {
self.relevant_file_statuses.get(action_id)
}
fn set_relevant_file_status(
&mut self,
action_id: &AIAgentActionId,
status: GetRelevantFilesStatus,
) {
self.relevant_file_statuses
.insert(action_id.clone(), status);
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn pending_request_details_for_retrieval_id(
&self,
pending_retrieval_id: &RetrievalID,
@@ -140,6 +219,7 @@ impl GetRelevantFilesController {
})
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn handle_codebase_manager_event(
&mut self,
_: ModelHandle<CodebaseIndexManager>,
@@ -213,7 +293,38 @@ impl GetRelevantFilesController {
self.cancel_request_for_action(&action_id, ctx);
match target {
GetRelevantFilesRequestTarget::Local { directory } => {
self.send_local_request(&directory, query, partial_path_segments, action_id, ctx)
let root_repo_path = self
.relevant_file_statuses
.get(&action_id)
.and_then(|status| match status {
GetRelevantFilesStatus::Pending { root_repo_path }
| GetRelevantFilesStatus::InFlight { root_repo_path } => {
Some(root_repo_path.clone())
}
GetRelevantFilesStatus::Success { .. }
| GetRelevantFilesStatus::Failed { .. } => None,
})
.unwrap_or_else(|| self.root_for_status(&directory, ctx));
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::InFlight { root_repo_path },
);
let result = self.send_local_request(
&directory,
query,
partial_path_segments,
action_id.clone(),
ctx,
);
if let Err(error) = &result {
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::Failed {
message: error.to_string(),
},
);
}
result
}
GetRelevantFilesRequestTarget::Remote {
session_context,
@@ -237,8 +348,45 @@ impl GetRelevantFilesController {
action_id: AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> Result<(), GetRelevantFilesError> {
const MINIMUM_FILE_COUNT_FOR_API_CALL: usize = 2;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
let Some(local_index) =
ctx.try_get_singleton_model_as_ref::<LocalProjectIndexManager>()
else {
return Err(LocalIndexUnavailable(
"The local project index is not available in this session.".to_string(),
));
};
if local_index.is_searchable_for_path(directory) {
let locations: HashSet<CodeContextLocation> = local_index
.search(directory, &query, partial_path_segments.map(Vec::as_slice))
.map_err(|error| LocalIndexUnavailable(error.to_string()))?
.into_iter()
.map(|hit| CodeContextLocation::WholeFile(hit.path))
.collect();
let file_paths = locations
.iter()
.map(|location| location.path().clone())
.collect();
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::Success { file_paths },
);
ctx.emit(GetRelevantFilesControllerEvent::Success {
action_id,
result: GetRelevantFilesControllerResult::Locations(Arc::new(locations)),
});
return Ok(());
}
return Err(LocalIndexUnavailable(
"The local project index is not ready. Run /init or /index first.".to_string(),
));
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
let _ = (directory, query, partial_path_segments, action_id, ctx);
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
if FeatureFlag::FullSourceCodeEmbedding.is_enabled() {
let codebase_mgr = CodebaseIndexManager::handle(ctx);
if let Some(base_path) = codebase_mgr.as_ref(ctx).root_path_for_codebase(directory) {
@@ -268,79 +416,93 @@ impl GetRelevantFilesController {
}
}
match RepoOutlines::as_ref(ctx).get_outline(directory) {
Some((OutlineStatus::Complete(outline), base_path)) => {
let server_api = ServerApiProvider::as_ref(ctx).get();
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
match RepoOutlines::as_ref(ctx).get_outline(directory) {
Some((OutlineStatus::Complete(outline), base_path)) => {
let server_api = ServerApiProvider::as_ref(ctx).get();
let file_outlines = outline.to_file_symbols(partial_path_segments);
if file_outlines.len() < MINIMUM_FILE_COUNT_FOR_API_CALL {
ctx.emit(GetRelevantFilesControllerEvent::Success {
action_id,
result: GetRelevantFilesControllerResult::Locations(Arc::new(
let file_outlines = outline.to_file_symbols(partial_path_segments);
if file_outlines.len() < MINIMUM_FILE_COUNT_FOR_API_CALL {
let locations: Arc<HashSet<CodeContextLocation>> = Arc::new(
file_outlines
.into_iter()
.map(|file| {
CodeContextLocation::WholeFile(PathBuf::from(file.path))
})
.collect(),
)),
});
} else {
let outline_request = GetRelevantFiles {
query,
files: file_outlines
.into_iter()
.map(|outline| FileContextRequest {
path: outline.path,
symbols: outline.symbols,
})
.collect(),
};
let action_id_clone = action_id.clone();
let request_abort_handle = ctx
.spawn(
async move {
let response =
server_api.get_relevant_files(&outline_request).await?;
Ok(Arc::new(
response
.relevant_file_paths
.into_iter()
.filter_map(|path| {
let file_path = base_path.join(path);
// Validate the returned file paths.
if file_path.exists() {
Some(CodeContextLocation::WholeFile(file_path))
} else {
None
}
})
.collect(),
))
},
move |me,
relevant_file_paths: Result<
Arc<HashSet<CodeContextLocation>>,
AIApiError,
>,
ctx| {
me.handle_relevant_file_paths_result(
relevant_file_paths.map_err(|e| anyhow!(e)),
action_id_clone,
ctx,
)
},
)
.abort_handle();
self.pending_requests
.insert(action_id, RequestHandle::AbortHandle(request_abort_handle));
);
let file_paths = locations
.iter()
.map(|location| location.path().clone())
.collect();
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::Success { file_paths },
);
ctx.emit(GetRelevantFilesControllerEvent::Success {
action_id,
result: GetRelevantFilesControllerResult::Locations(locations),
});
} else {
let outline_request = GetRelevantFiles {
query,
files: file_outlines
.into_iter()
.map(|outline| FileContextRequest {
path: outline.path,
symbols: outline.symbols,
})
.collect(),
};
let action_id_clone = action_id.clone();
let request_abort_handle = ctx
.spawn(
async move {
let response =
server_api.get_relevant_files(&outline_request).await?;
Ok(Arc::new(
response
.relevant_file_paths
.into_iter()
.filter_map(|path| {
let file_path = base_path.join(path);
// Validate the returned file paths.
if file_path.exists() {
Some(CodeContextLocation::WholeFile(file_path))
} else {
None
}
})
.collect(),
))
},
move |me,
relevant_file_paths: Result<
Arc<HashSet<CodeContextLocation>>,
AIApiError,
>,
ctx| {
me.handle_relevant_file_paths_result(
relevant_file_paths.map_err(|e| anyhow!(e)),
action_id_clone,
ctx,
)
},
)
.abort_handle();
self.pending_requests
.insert(action_id, RequestHandle::AbortHandle(request_abort_handle));
}
Ok(())
}
Ok(())
Some((OutlineStatus::Pending, _)) => Err(GetRelevantFilesError::Pending),
Some((OutlineStatus::Failed, _)) => Err(GetRelevantFilesError::CreateFailed),
None => Err(GetRelevantFilesError::Missing),
}
Some((OutlineStatus::Pending, _)) => Err(GetRelevantFilesError::Pending),
Some((OutlineStatus::Failed, _)) => Err(GetRelevantFilesError::CreateFailed),
None => Err(GetRelevantFilesError::Missing),
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
unreachable!("native local search returns before the legacy outline fallback")
}
fn send_remote_request(
@@ -375,6 +537,7 @@ impl GetRelevantFilesController {
Ok(())
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn handle_relevant_file_paths_result(
&mut self,
relevant_file_locations: anyhow::Result<Arc<HashSet<CodeContextLocation>>>,
@@ -386,12 +549,27 @@ impl GetRelevantFilesController {
}
match relevant_file_locations {
Ok(relevant_file_locations) => {
let file_paths = relevant_file_locations
.iter()
.map(|location| location.path().clone())
.collect();
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::Success { file_paths },
);
ctx.emit(GetRelevantFilesControllerEvent::Success {
action_id,
result: GetRelevantFilesControllerResult::Locations(relevant_file_locations),
});
}
Err(e) => {
let message = e.to_string();
self.set_relevant_file_status(
&action_id,
GetRelevantFilesStatus::Failed {
message: message.clone(),
},
);
report_error!(anyhow!(e).context("get_relevant_files failed"));
ctx.emit(GetRelevantFilesControllerEvent::Error { action_id });
}
@@ -421,15 +599,24 @@ impl GetRelevantFilesController {
/// Returns the path to the root directory for a codebase search where pwd is `directory`.
pub fn root_directory_for_search(&self, directory: &Path, app: &AppContext) -> Option<PathBuf> {
let mut start = None;
if FeatureFlag::FullSourceCodeEmbedding.is_enabled() {
start = CodebaseIndexManager::as_ref(app).root_path_for_codebase(directory);
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
app.try_get_singleton_model_as_ref::<LocalProjectIndexManager>()
.and_then(|manager| manager.root_for_path(directory))
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
let mut start = None;
if FeatureFlag::FullSourceCodeEmbedding.is_enabled() {
start = CodebaseIndexManager::as_ref(app).root_path_for_codebase(directory);
}
start.or_else(|| {
RepoOutlines::as_ref(app)
.get_outline(directory)
.map(|(_, root)| root)
})
}
start.or_else(|| {
RepoOutlines::as_ref(app)
.get_outline(directory)
.map(|(_, root)| root)
})
}
pub fn root_directory_for_remote_search(
+13 -19
View File
@@ -779,7 +779,7 @@ impl LLMPreferences {
if !*settings.bedrock_enabled.value() {
return;
}
let config = crate::ai::bedrock::client::BedrockClientConfig {
let config = crate::ai::provider::client::BedrockClientConfig {
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
@@ -787,11 +787,10 @@ impl LLMPreferences {
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
use_rig: false,
};
let _ = ctx.spawn(
async move { crate::ai::bedrock::discovery::discover_available_models(config).await },
async move { crate::ai::provider::discovery::discover_available_models(config).await },
|me, result, ctx| match result {
Ok(models) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
@@ -839,7 +838,7 @@ impl LLMPreferences {
let cross_region = *settings.bedrock_cross_region_inference.value();
// Check if user wants only 1-hour cache models
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
use crate::ai::provider::external_config::ExternalBedrockConfig;
let external_config = ExternalBedrockConfig::load();
let require_1h_cache = external_config.enable_prompt_caching_1h;
@@ -885,7 +884,7 @@ impl LLMPreferences {
}
let model_id = if cross_region && !region.is_empty() {
super::bedrock::models::apply_cross_region_prefix(&model.model_id, &region)
super::provider::models::apply_cross_region_prefix(&model.model_id, &region)
} else {
model.model_id.clone()
};
@@ -1173,11 +1172,6 @@ impl LLMPreferences {
reasoning_effort: reasoning_effort.clone(),
max_input_tokens: Some(openai_model_context_size(model)),
max_output_tokens: model.max_output_tokens,
use_rig: model.use_rig
|| !matches!(
provider_kind,
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM
),
supports_system_messages: model.supports_system_messages(),
};
self.openai_provider_routing
@@ -1841,7 +1835,6 @@ impl LLMPreferences {
max_input_tokens: model.context_size,
max_output_tokens: None,
provider: None,
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
@@ -2658,15 +2651,12 @@ pub(crate) fn merge_discovered_provider_models(
{
discovered.display_name = existing.display_name.clone();
discovered.enabled = existing.enabled;
discovered.use_rig = existing.use_rig;
if existing.supports_system_messages.is_some() {
discovered.supports_system_messages = existing.supports_system_messages;
}
if discovered.provider.is_none() {
discovered.provider = existing.provider.clone();
}
} else {
discovered.use_rig = true;
}
if discovered.model_id.starts_with("codex-gpt-") {
discovered.supports_system_messages = Some(false);
@@ -2707,7 +2697,6 @@ pub(crate) fn merge_discovered_chatgpt_subscription_models(
.find(|model| model.model_id == discovered.model_id)
{
discovered.enabled = existing.enabled;
discovered.use_rig = existing.use_rig;
if existing.supports_system_messages.is_some() {
discovered.supports_system_messages = existing.supports_system_messages;
}
@@ -2742,7 +2731,15 @@ fn openai_model_context_window(
is_configurable: max_context_size > default_context_size,
min: default_context_size,
max: max_context_size,
default_max: default_context_size,
// ChatGPT's catalog exposes `context_window` as the effective window
// and `max_context_window` as the model's actual maximum. Use the
// latter as the default so model refreshes do not reset the profile
// to the smaller effective window (for example, 272k).
default_max: if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
max_context_size
} else {
default_context_size
},
}
}
@@ -2926,7 +2923,6 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
max_input_tokens,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts,
@@ -3048,7 +3044,6 @@ async fn fetch_from_litellm_model_info(
max_input_tokens,
max_output_tokens,
provider,
use_rig: false,
supports_system_messages: if model_name.starts_with("codex-gpt-") {
Some(false)
} else {
@@ -3178,7 +3173,6 @@ async fn fetch_from_openai_models(
max_input_tokens,
max_output_tokens,
provider,
use_rig: false,
supports_system_messages: if id.starts_with("codex-gpt-") {
Some(false)
} else {
+8 -11
View File
@@ -151,7 +151,6 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
max_input_tokens: None,
max_output_tokens: None,
provider: None,
use_rig: false,
supports_system_messages: None,
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
@@ -164,7 +163,6 @@ fn bedrock_model(model_id: &str) -> BedrockModelConfig {
model_id: model_id.to_string(),
display_name: model_id.to_string(),
vision_supported: false,
use_rig: true,
}
}
@@ -308,7 +306,6 @@ fn provider_discovery_preserves_local_model_overrides() {
existing.display_name = "My Codex".to_string();
existing.context_size = 100_000;
existing.provider = Some("openai".to_string());
existing.use_rig = true;
// Even stale or incorrect endpoint metadata must not opt ChatGPT-backed
// Codex models back into the system role.
existing.supports_system_messages = Some(true);
@@ -325,7 +322,6 @@ fn provider_discovery_preserves_local_model_overrides() {
assert_eq!(merged[0].display_name, "My Codex");
assert_eq!(merged[0].context_size, 400_000);
assert_eq!(merged[0].max_output_tokens, Some(32_000));
assert!(merged[0].use_rig);
assert_eq!(merged[0].supports_system_messages, Some(false));
assert_eq!(merged[0].provider.as_deref(), Some("openai"));
}
@@ -725,7 +721,7 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() {
}
#[test]
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
fn provider_discovery_keeps_new_and_manual_models() {
let manual = openai_model("manual-model");
let discovered = openai_model("codex-gpt-new");
@@ -733,7 +729,6 @@ fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].model_id, "codex-gpt-new");
assert!(merged[0].use_rig);
assert_eq!(merged[0].supports_system_messages, Some(false));
assert_eq!(merged[1].model_id, "manual-model");
}
@@ -798,7 +793,6 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
assert_eq!(models[0].context_size, 828_400);
assert_eq!(models[0].max_input_tokens, Some(258_400));
assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]);
assert!(models[0].use_rig);
assert_eq!(models[0].provider.as_deref(), Some("openai"));
assert_eq!(models[0].supports_system_messages, Some(true));
@@ -813,7 +807,7 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
);
assert!(configurable.is_configurable);
assert_eq!(configurable.min, 258_400);
assert_eq!(configurable.default_max, 258_400);
assert_eq!(configurable.default_max, 828_400);
assert_eq!(configurable.max, 828_400);
let fixed =
@@ -828,7 +822,8 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
fn chatgpt_catalog_merge_drops_stale_models_but_preserves_overrides() {
let mut existing = openai_model("gpt-5.6-sol");
existing.enabled = false;
existing.use_rig = false;
existing.context_size = 272_000;
existing.max_input_tokens = Some(272_000);
existing.capability_overrides.insert(
"vision".to_string(),
crate::settings::ModelCapabilityOverride::Unsupported,
@@ -838,14 +833,16 @@ fn chatgpt_catalog_merge_drops_stale_models_but_preserves_overrides() {
let mut discovered = openai_model("gpt-5.6-sol");
discovered.display_name = "GPT-5.6-Sol".to_string();
discovered.vision_supported = true;
discovered.use_rig = true;
discovered.context_size = 828_400;
discovered.max_input_tokens = Some(258_400);
let merged = merge_discovered_chatgpt_subscription_models(&[existing, stale], vec![discovered]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].model_id, "gpt-5.6-sol");
assert_eq!(merged[0].context_size, 828_400);
assert_eq!(merged[0].max_input_tokens, Some(258_400));
assert!(!merged[0].enabled);
assert!(!merged[0].use_rig);
assert_eq!(
merged[0].capability_override("vision"),
crate::settings::ModelCapabilityOverride::Unsupported
-2
View File
@@ -19,8 +19,6 @@ pub mod auth_secret_types;
#[cfg(not(target_family = "wasm"))]
pub mod aws_credentials;
#[cfg(not(target_family = "wasm"))]
pub mod bedrock;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod bedrock_credentials;
pub(crate) mod block_context;
pub(crate) mod blocklist;
-1
View File
@@ -17,7 +17,6 @@ pub struct OpenAIClientConfig {
pub reasoning_effort: Option<String>,
pub max_input_tokens: Option<u32>,
pub max_output_tokens: Option<u32>,
pub use_rig: bool,
pub supports_system_messages: bool,
}
+2 -2
View File
@@ -10,7 +10,7 @@ use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use crate::ai::agent::api::LegacyEvent;
use crate::ai::bedrock::response_translator::{
use crate::ai::provider::response_translator::{
build_create_task, build_stream_init, context_window_for_model,
};
use crate::ai::provider::types::{ContentPart, ConversationMessage, MessageContent, MessageRole};
@@ -531,7 +531,7 @@ fn build_tool_call_message(
tool_input_json: &str,
) -> ResponseEvent {
// Reuse the Bedrock tool call message builder since the proto output is identical
crate::ai::bedrock::response_translator::build_tool_call_message(
crate::ai::provider::response_translator::build_tool_call_message(
task_id,
tool_use_id,
tool_name,
+136 -10
View File
@@ -3,15 +3,21 @@ use std::path::{Path, PathBuf};
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::local_project_index::LocalProjectIndexManager;
use ai::project_context::model::{ProjectContextModel, ProjectContextModelEvent};
use ai::workspace::{WorkspaceMetadata, WorkspaceMetadataEvent};
use anyhow::Context;
use chrono::Utc;
#[cfg(feature = "local_fs")]
use galaxy_core::channel::ChannelState;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use galaxy_core::execution_mode::AppExecutionMode;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use galaxy_core::features::FeatureFlag;
#[cfg(feature = "local_fs")]
use galaxy_util::{local_or_remote_path::LocalOrRemotePath, standardized_path::StandardizedPath};
@@ -46,6 +52,7 @@ use crate::code::lsp_telemetry::LspTelemetryEvent;
use crate::persistence::ModelEvent;
#[cfg(feature = "local_fs")]
use crate::server::server_api::ServerApiProvider;
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
use crate::settings::CodeSettings;
#[cfg(feature = "local_fs")]
use crate::terminal::local_shell::LocalShellState;
@@ -55,6 +62,12 @@ use crate::{report_if_error, send_telemetry_from_ctx, TelemetryEvent};
#[cfg(feature = "local_fs")]
use crate::{view_components::DismissibleToast, workspace::ToastStack};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn local_project_indexing_enabled(ctx: &AppContext) -> bool {
ctx.try_get_singleton_model_as_ref::<AppExecutionMode>()
.is_some_and(|mode| mode.local_project_indexing_enabled())
}
/// Represents whether an LSP server is enabled or disabled for a workspace.
///
/// This is also used in underlying sqlite type persistence. We should be careful
@@ -239,6 +252,7 @@ impl PersistedWorkspace {
})
.collect();
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
if FeatureFlag::FullSourceCodeEmbedding.is_enabled() {
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), |me, _, event, ctx| {
match event {
@@ -266,7 +280,7 @@ impl PersistedWorkspace {
..
} = event
{
#[cfg(feature = "local_fs")]
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
me.clean_up_deleted_indices(ctx);
me.trigger_incremental_sync_for_conversation(*terminal_surface_id, ctx);
@@ -310,12 +324,68 @@ impl PersistedWorkspace {
});
}
#[cfg(feature = "local_fs")]
if !cfg!(any(
test,
feature = "fast_dev",
feature = "integration_tests"
)) && CodebaseIndexManager::as_ref(ctx).is_indexing_enabled()
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if local_project_indexing_enabled(ctx) {
ctx.subscribe_to_model(
&UserWorkspaces::handle(ctx),
|me, _, user_workspaces_event, ctx| {
if let UserWorkspacesEvent::CodebaseContextEnablementChanged =
user_workspaces_event
{
me.on_settings_changed(ctx);
}
},
);
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if local_project_indexing_enabled(ctx) {
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if let BlocklistAIHistoryEvent::StartedNewConversation {
terminal_surface_id,
..
} = event
{
me.trigger_incremental_sync_for_conversation(*terminal_surface_id, ctx);
}
},
);
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if local_project_indexing_enabled(ctx) {
ctx.subscribe_to_model(&ProjectContextModel::handle(ctx), |me, _, event, _ctx| {
if let ProjectContextModelEvent::KnownRulesChanged(delta) = event {
let mut events = vec![];
if !delta.discovered_rules.is_empty() {
events.push(ModelEvent::UpsertProjectRules {
project_rule_paths: delta.discovered_rules.clone(),
});
}
if !delta.deleted_rules.is_empty() {
events.push(ModelEvent::DeleteProjectRules {
path: delta.deleted_rules.clone(),
});
}
if !events.is_empty() {
me.save_to_db(events);
}
}
});
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if local_project_indexing_enabled(ctx)
&& !cfg!(any(
test,
feature = "fast_dev",
feature = "integration_tests"
))
{
ctx.subscribe_to_model(&DetectedRepositories::handle(ctx), |me, _, event, ctx| {
let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event;
@@ -634,6 +704,33 @@ impl PersistedWorkspace {
/// Enables or disables codebase indexing according to the setting.
fn maybe_enable_codebase_indexing(ctx: &mut ModelContext<Self>) {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
if local_project_indexing_enabled(ctx)
&& should_auto_index_codebase(CodebaseAutoIndexingSurface::Local, ctx)
{
for root in auto_index_candidate_roots(
all_working_directories(ctx)
.into_iter()
.filter_map(|directory| {
DetectedRepositories::as_ref(ctx)
.get_root_for_path(&LocalOrRemotePath::Local(directory))
.and_then(|root| root.to_local_path().map(Path::to_path_buf))
}),
|_| true,
) {
if let Err(error) = LocalProjectIndexManager::handle(ctx)
.update(ctx, |manager, ctx| {
manager.index_directory(root.to_path_buf(), ctx)
})
{
log::warn!("Failed to start local project indexing: {error:#}");
}
}
}
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
if !manager.is_indexing_enabled() {
return;
@@ -648,6 +745,7 @@ impl PersistedWorkspace {
});
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn enable_codebase_indexing(
manager: &mut CodebaseIndexManager,
ctx: &mut ModelContext<CodebaseIndexManager>,
@@ -661,7 +759,7 @@ impl PersistedWorkspace {
ctx,
);
#[cfg(feature = "local_fs")]
#[cfg(all(feature = "local_fs", target_family = "wasm"))]
if should_auto_index_codebase(CodebaseAutoIndexingSurface::Local, ctx) {
let roots = all_working_directories(ctx).into_iter().filter_map(|dir| {
DetectedRepositories::as_ref(ctx)
@@ -683,6 +781,17 @@ impl PersistedWorkspace {
ctx,
);
});
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if local_project_indexing_enabled(ctx)
&& should_auto_index_codebase(CodebaseAutoIndexingSurface::Local, ctx)
{
if let Err(error) = LocalProjectIndexManager::handle(ctx).update(ctx, |manager, ctx| {
manager.index_directory(directory_path.clone(), ctx)
}) {
log::warn!("Failed to start local project indexing: {error:#}");
}
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
if FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx)
&& *CodeSettings::as_ref(ctx).auto_indexing_enabled
@@ -700,6 +809,8 @@ impl PersistedWorkspace {
/// scanning, and emits
/// [`PersistedWorkspaceEvent::WorkspaceAdded`] so subscribers can refresh their UI.
pub fn user_added_workspace(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
#[cfg(feature = "local_fs")]
let path = dunce::canonicalize(&path).unwrap_or(path);
let now = Utc::now();
match self.workspaces.get_mut(&path) {
@@ -850,7 +961,15 @@ impl PersistedWorkspace {
if let Some(pwd) = pwd {
let directory_path = PathBuf::from(pwd);
// Trigger an incremental sync through the CodebaseIndexManager
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if let Err(error) = LocalProjectIndexManager::handle(ctx)
.update(ctx, |manager, ctx| {
manager.index_directory(directory_path.clone(), ctx)
})
{
log::warn!("Failed to refresh local project index: {error:#}");
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
CodebaseIndexManager::handle(ctx).update(ctx, |codebase_manager, ctx| {
if let Err(e) = codebase_manager
.trigger_incremental_sync_for_path(&directory_path, ctx)
@@ -907,7 +1026,7 @@ impl PersistedWorkspace {
}));
}
#[cfg(feature = "local_fs")]
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
fn clean_up_deleted_indices(&self, ctx: &mut ModelContext<Self>) {
CodebaseIndexManager::handle(ctx).update(ctx, |codebase_manager, ctx| {
codebase_manager.clean_up_deleted_indices(ctx);
@@ -1417,6 +1536,13 @@ impl PersistedWorkspace {
}
fn send_active_indexed_repos_changed_telemetry<T: Entity>(ctx: &mut ModelContext<T>) {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
let total = if local_project_indexing_enabled(ctx) {
LocalProjectIndexManager::as_ref(ctx).statuses().count()
} else {
0
};
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
let total = CodebaseIndexManager::as_ref(ctx).num_active_indices();
let hit_max = AIRequestUsageModel::as_ref(ctx).hit_codebase_index_limit(total);
send_telemetry_from_ctx!(
+1 -1
View File
@@ -26,7 +26,7 @@ pub use prompts::provider::Provider;
#[allow(unused_imports)]
pub use tools::tools_for_mode;
use crate::ai::bedrock::convert::ToolDefinition;
use crate::ai::provider::convert::ToolDefinition;
/// The fully-resolved prompt configuration ready to send to a model.
#[derive(Debug, Clone)]
+15 -13
View File
@@ -34,10 +34,11 @@ const CODE_INSTRUCTIONS: &str = r#"## Instructions
### Doing Tasks
The user will primarily request software engineering tasks: solving bugs, adding features, refactoring, explaining code, and more. For these tasks:
1. Use search tools to understand the codebase and the user's query. Search extensively — both in parallel and sequentially.
2. Implement the solution using all tools available to you.
3. Verify the solution if possible with tests. NEVER assume a specific test framework — check the project first.
4. After making changes, run lint/typecheck/build commands if you know them, to ensure correctness.
1. For source-code discovery, semantic questions, or finding an unfamiliar implementation, use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve it for exact known text in non-code files. Use `file_glob` to locate filenames and `read_files` for focused follow-up context.
2. Search extensively — both in parallel and sequentially.
3. Implement the solution using all tools available to you.
4. Verify the solution if possible with tests. NEVER assume a specific test framework — check the project first.
5. After making changes, run lint/typecheck/build commands if you know them, to ensure correctness.
NEVER commit changes unless the user explicitly asks you to.
@@ -62,7 +63,7 @@ CRITICAL: You are in READ-ONLY planning mode. You MUST NOT:
You MAY:
- Read files
- Search the codebase (grep, glob)
- Search the codebase with `search_codebase`; use `file_glob` for filenames and `grep` only for exact known text in non-code files
- Run read-only shell commands (ls, cat, git log, git status)
- Ask the user clarifying questions
@@ -116,7 +117,8 @@ const CODE_TOOL_GUIDELINES: &str = r#"## Tool Usage
You have been given every tool you need to complete your tasks. Use them to achieve results with as few calls and as little back-and-forth as possible.
**How to choose tools:**
- For reading, writing, searching, and navigating files on the local filesystem, use your filesystem tools (`read_files`, `file_glob`, `grep`, `apply_file_diffs`).
- For source-code discovery, use `search_codebase` first. Use `file_glob` to locate filenames and `read_files` to inspect focused files. Never use `grep` to discover or search source code when `search_codebase` is available; reserve it for exact known text in non-code files.
- For writing and editing files, use `apply_file_diffs`.
- For running commands, installing packages, building, testing, and any shell operation, use `run_shell_command`.
- For tasks that require interacting with external services, web UIs, or capabilities not covered by your filesystem and shell tools, use your MCP tools.
- For complex multi-step tasks where a single script would replace many tool calls, write code (Python, Node, bash) via `run_shell_command` to reduce round-trips. But never use scripts for simple operations that a single command handles.
@@ -140,10 +142,10 @@ If you intend to call multiple tools and there are no dependencies between the c
const PLAN_TOOL_GUIDELINES: &str = r#"## Tool Usage
You have access to read-only tools for exploring the codebase:
- `read_files` — Read file contents
- `grep` — Search for patterns in files
- `search_codebase` — Primary tool for source-code discovery and semantic or structural questions
- `file_glob` — Find files by glob pattern
- `search_codebase` — Semantic code search
- `read_files` — Read focused file contents after discovery
- `grep` — Search exact known text in non-code files only
- `run_shell_command` — ONLY for read-only commands (ls, git log, git status, etc.)
- `ask_user_question` — Ask the user for clarification
@@ -151,10 +153,10 @@ You MUST NOT use `apply_file_diffs`, `create_documents`, `edit_documents`, or an
const REVIEW_TOOL_GUIDELINES: &str = r#"## Tool Usage
You have access to tools for examining the code under review:
- `read_files` — Read file contents to understand context
- `grep` — Search for patterns to find related code
- `file_glob` — Find related files
- `search_codebase` — Semantic search for related implementations
- `search_codebase` — Primary tool for discovering relevant source code and understanding implementations semantically
- `file_glob` — Find related filenames
- `read_files` — Read focused file contents after discovery
- `grep` — Search exact known text in non-code files, or use only as a fallback when the semantic index is unavailable
- `run_shell_command` — For read-only commands (git diff, git log, etc.)
Use these tools to gather context needed for a thorough review. You should read the files being changed and their surrounding context before providing feedback."#;
+29 -1
View File
@@ -16,6 +16,7 @@ mod prompt_builder_tests {
assert!(prompt.system_prompt.contains("Galaxy"));
assert!(prompt.system_prompt.contains("coding mode"));
assert!(prompt.system_prompt.contains("/home/user/project"));
assert!(prompt.system_prompt.contains("search_codebase` first"));
assert!(!prompt.tools.is_empty());
// Code mode should have apply_file_diffs
assert!(prompt.tools.iter().any(|t| t.name == "apply_file_diffs"));
@@ -31,6 +32,33 @@ mod prompt_builder_tests {
assert!(!prompt.tools.iter().any(|t| t.name == "apply_file_diffs"));
// But should have read_files
assert!(prompt.tools.iter().any(|t| t.name == "read_files"));
assert!(prompt.system_prompt.contains("search_codebase` first"));
}
#[test]
fn test_semantic_search_is_advertised_before_regex_search() {
let prompt = PromptBuilder::new(Mode::Code, Provider::Anthropic).build();
let search_index = prompt
.tools
.iter()
.position(|tool| tool.name == "search_codebase")
.expect("search_codebase tool");
let grep_index = prompt
.tools
.iter()
.position(|tool| tool.name == "grep")
.expect("grep tool");
assert!(search_index < grep_index);
assert!(prompt
.tools
.iter()
.find(|tool| tool.name == "search_codebase")
.is_some_and(|tool| tool.description.contains("all source-code discovery")));
assert!(prompt
.tools
.iter()
.find(|tool| tool.name == "grep")
.is_some_and(|tool| tool.description.contains("non-code files")));
}
#[test]
@@ -75,7 +103,7 @@ mod prompt_builder_tests {
#[test]
fn test_mcp_tools_appended() {
use crate::ai::bedrock::convert::ToolDefinition;
use crate::ai::provider::convert::ToolDefinition;
let mcp_tool = ToolDefinition {
name: "mcp__github__create_pr".to_string(),
+10 -10
View File
@@ -3,7 +3,7 @@
//! Each mode has a different set of tools available. Code mode gets everything,
//! Plan mode gets read-only tools, Review mode gets read + search, etc.
use crate::ai::bedrock::convert::ToolDefinition;
use crate::ai::provider::convert::ToolDefinition;
use crate::ai::prompt_builder::mode::Mode;
/// Returns the tool definitions available for the given mode.
@@ -23,9 +23,9 @@ fn code_tools() -> Vec<ToolDefinition> {
run_shell_command(),
read_files(),
apply_file_diffs(),
grep(),
file_glob(),
search_codebase(),
file_glob(),
grep(),
write_to_long_running_shell_command(),
interrupt_shell_command(),
read_shell_command_output(),
@@ -48,9 +48,9 @@ fn code_tools() -> Vec<ToolDefinition> {
fn plan_tools() -> Vec<ToolDefinition> {
vec![
read_files(),
grep(),
file_glob(),
search_codebase(),
file_glob(),
grep(),
run_shell_command_readonly(),
ask_user_question(),
start_agent(),
@@ -64,9 +64,9 @@ fn plan_tools() -> Vec<ToolDefinition> {
fn review_tools() -> Vec<ToolDefinition> {
vec![
read_files(),
grep(),
file_glob(),
search_codebase(),
file_glob(),
grep(),
run_shell_command_readonly(),
]
}
@@ -136,7 +136,7 @@ fn apply_file_diffs() -> ToolDefinition {
fn grep() -> ToolDefinition {
ToolDefinition {
name: "grep".to_string(),
description: "Search for up to 3 focused regex patterns per call. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns file paths and matching line numbers. Use read_files afterward to see context around matches.".to_string(),
description: "Search for up to 3 focused regex patterns per call. Use only for exact known text in non-code files; never use this to discover or search source code when search_codebase is available. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -151,7 +151,7 @@ fn grep() -> ToolDefinition {
fn file_glob() -> ToolDefinition {
ToolDefinition {
name: "file_glob".to_string(),
description: "Find files matching up to 3 focused glob patterns per call. Uses git ls-files in git repos. Returns absolute file paths of matches. Make another call for more patterns.".to_string(),
description: "Find filenames matching up to 3 focused glob patterns per call. Use this to locate files, not to search their contents. Uses git ls-files in git repos. Returns absolute file paths of matches. Make another call for more patterns.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -166,7 +166,7 @@ fn file_glob() -> ToolDefinition {
fn search_codebase() -> ToolDefinition {
ToolDefinition {
name: "search_codebase".to_string(),
description: "Semantic code search across the indexed codebase. Use for finding relevant code by meaning rather than exact text match. Better than grep for conceptual queries like 'authentication logic' or 'error handling for database connections'.".to_string(),
description: "Primary tool for discovering relevant source code in the local project index. Use this first for all source-code discovery and conceptual, semantic, or structural searches before reading files. Never use grep to search source code when this tool is available.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -38,7 +38,6 @@ pub struct BedrockClientConfig {
pub secret_access_key: String,
pub session_token: Option<String>,
pub cross_region_inference: bool,
pub use_rig: bool,
}
impl BedrockClientConfig {
@@ -216,13 +215,6 @@ impl BedrockClient {
tools.len()
);
log::info!(
"[bedrock] Sending request payload to Bedrock:\nSystem Prompt: {:?}\nMessages: {:#?}\nTools: {:#?}",
system_prompt,
messages,
tools
);
let converted = build_converse_request(
messages.clone(),
system_prompt.clone(),
@@ -743,7 +743,7 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
},
"grep" => ToolDefinition {
name: "grep".to_string(),
description: "Search for patterns in files. Pass all search patterns in one call.".to_string(),
description: "Search for patterns in files. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Pass all search patterns in one call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -88,7 +88,6 @@ pub async fn discover_available_models(
model_id: model_id.to_owned(),
display_name,
vision_supported,
use_rig: true,
})
}
});
@@ -172,40 +171,5 @@ fn prettify_model_id(model_id: &str) -> String {
}
#[cfg(test)]
mod tests {
use super::model_availability_is_usable;
#[test]
fn requires_every_availability_status() {
assert!(model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
None,
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"NOT_AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"NOT_AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"NOT_AVAILABLE",
));
}
}
#[path = "discovery_tests.rs"]
mod tests;
+35
View File
@@ -0,0 +1,35 @@
use super::model_availability_is_usable;
#[test]
fn requires_every_availability_status() {
assert!(model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
None,
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"NOT_AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"NOT_AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"NOT_AVAILABLE",
));
}
@@ -269,7 +269,6 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
@@ -280,7 +279,7 @@ fn get_test_model() -> String {
}
fn sample_project_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/ai/bedrock/test_fixtures/sample_project")
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/ai/provider/test_fixtures/sample_project")
}
fn agent_tools() -> Vec<ToolDefinition> {
@@ -144,7 +144,6 @@ fn parse_claude_code_model_map(
model_id: arn,
display_name,
vision_supported: true,
use_rig: false,
}
})
.collect()
@@ -24,7 +24,6 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
+44 -1
View File
@@ -1,12 +1,55 @@
pub mod types;
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::openai::client::OpenAIClientConfig;
#[cfg(not(target_family = "wasm"))]
use crate::ai::provider::client::BedrockClientConfig;
#[allow(dead_code)]
#[derive(Clone)]
pub enum ProviderConfig {
#[cfg(not(target_family = "wasm"))]
Bedrock(BedrockClientConfig),
OpenAI(OpenAIClientConfig),
None,
}
// Provider-neutral module boundary; Bedrock-specific transport helpers live here without a
// provider-named directory so the provider layer can be reorganized independently of callers.
#[cfg(not(target_family = "wasm"))]
pub mod client;
#[cfg(not(target_family = "wasm"))]
pub mod convert;
#[cfg(not(target_family = "wasm"))]
pub mod crash_log;
#[cfg(not(target_family = "wasm"))]
pub mod diagnostic;
#[cfg(not(target_family = "wasm"))]
pub mod discovery;
#[cfg(not(target_family = "wasm"))]
pub mod external_config;
#[cfg(not(target_family = "wasm"))]
pub mod models;
#[cfg(not(target_family = "wasm"))]
pub mod request_translator;
#[cfg(not(target_family = "wasm"))]
pub mod response_translator;
#[cfg(not(target_family = "wasm"))]
pub mod settings_view;
#[cfg(not(target_family = "wasm"))]
#[cfg(test)]
mod convert_tests;
#[cfg(not(target_family = "wasm"))]
#[cfg(test)]
#[allow(dead_code)]
mod e2e_tests;
#[cfg(not(target_family = "wasm"))]
#[cfg(test)]
#[allow(dead_code)]
mod integration_tests;
#[cfg(not(target_family = "wasm"))]
#[cfg(test)]
mod models_tests;
#[cfg(not(target_family = "wasm"))]
#[cfg(test)]
mod response_translator_tests;
+19
View File
@@ -0,0 +1,19 @@
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
if model_id.starts_with("arn:") {
return model_id.to_string();
}
if model_id.contains('.') && model_id.split('.').next().unwrap_or("").len() <= 6 {
return model_id.to_string();
}
let prefix = match region {
r if r.starts_with("us-") || r.starts_with("ca-") => "us",
r if r.starts_with("eu-") || r == "il-central-1" => "eu",
r if r == "ap-northeast-1" || r == "ap-northeast-3" => "jp",
r if r == "ap-southeast-2" || r == "ap-southeast-4" || r == "ap-southeast-6" => "au",
r if r.starts_with("ap-") => "apac",
_ => return model_id.to_string(),
};
format!("{}.{}", prefix, model_id)
}
@@ -1,6 +1,4 @@
use super::models::*;
use crate::settings::ai::BedrockModelConfig;
#[test]
fn test_cross_region_prefix_us_east() {
assert_eq!(
@@ -83,32 +81,3 @@ fn test_cross_region_prefix_skips_arn() {
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
}
#[test]
fn rig_opt_in_matches_context_markers_and_resolved_inference_profiles() {
let configured = vec![BedrockModelConfig {
model_id: "anthropic.claude-test[1m]".to_string(),
display_name: "Claude Test".to_string(),
vision_supported: false,
use_rig: true,
}];
assert!(configured_model_uses_rig(
"us.anthropic.claude-test",
&configured,
"us-east-1",
true,
));
assert!(configured_model_uses_rig(
"anthropic.claude-test[1M]",
&configured,
"us-east-1",
false,
));
assert!(!configured_model_uses_rig(
"anthropic.other-model",
&configured,
"us-east-1",
false,
));
}
@@ -1558,6 +1558,11 @@ pub fn extract_system_prompt(
));
}
let has_tool = |name: &str| tool_names.iter().any(|tool_name| tool_name == name);
if has_tool("search_codebase") {
prompt.push_str(
"- For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context.\n",
);
}
if [
"read_files",
"apply_file_diffs",
@@ -1569,7 +1574,7 @@ pub fn extract_system_prompt(
.any(has_tool)
{
prompt.push_str(
"- Use filesystem/search tools to understand the codebase and focused diff tools to edit \
"- Use the appropriate filesystem/search tool to understand the codebase and focused diff tools to edit \
it. Use paths rooted in the working directory and absolute paths when a schema requires \
them.\n",
);
@@ -1783,7 +1788,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "grep".to_string(),
description: "Search for regex patterns in files. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns file paths and matching line numbers. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(),
description: "Search for regex patterns in files. Use only for exact known text in non-code files; never use this to discover or search source code when search_codebase is available. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -1795,7 +1800,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "file_glob".to_string(),
description: "Find files matching glob patterns. Uses git ls-files in git repos. Returns absolute file paths of matches. Common patterns: '**/*.rs', 'src/**/*.ts', '**/Cargo.toml'. Pass ALL patterns in one call.".to_string(),
description: "Find filenames matching glob patterns. Use this to locate files, not to search their contents. Uses git ls-files in git repos. Returns absolute file paths of matches. Common patterns: '**/*.rs', 'src/**/*.ts', '**/Cargo.toml'. Pass ALL patterns in one call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -1807,7 +1812,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "search_codebase".to_string(),
description: "Semantic code search across the indexed codebase. Use for finding relevant code by meaning rather than exact text match. Better than grep for conceptual queries like 'authentication logic' or 'error handling for database connections'.".to_string(),
description: "Primary tool for discovering relevant source code across the indexed codebase. MUST use this first for all source-code discovery and conceptual, semantic, or structural searches such as authentication logic or database error handling. Never use grep to search source code when this tool is available.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -6,7 +6,7 @@ use super::{
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use crate::ai::provider::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test]
fn test_sanitize_messages_prepends_synthetic_tool_result_before_existing_user_text() {
@@ -107,6 +107,38 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
);
}
#[test]
fn bedrock_prompt_prioritizes_semantic_code_search_over_regex_search() {
let request = api::Request {
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::SearchCodebase.into(),
api::ToolType::Grep.into(),
api::ToolType::FileGlob.into(),
api::ToolType::ReadFiles.into(),
],
..Default::default()
}),
..Default::default()
};
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
assert!(prompt.contains("MUST use `search_codebase` first"));
assert!(prompt.contains("Never use `grep` to discover or search source code"));
let tools = extract_tools(&request);
let search_codebase = tools
.iter()
.find(|tool| tool.name == "search_codebase")
.expect("search_codebase tool");
let grep = tools
.iter()
.find(|tool| tool.name == "grep")
.expect("grep tool");
assert!(search_codebase.description.contains("MUST use this first"));
assert!(grep.description.contains("non-code files"));
}
#[test]
fn direct_provider_advertises_local_tool_history_recall() {
let request = api::Request {
@@ -3,8 +3,8 @@ use galaxyui::elements::{
};
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::ai::bedrock::convert::CachingConfig;
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
use crate::ai::provider::convert::CachingConfig;
use crate::ai::provider::external_config::ExternalBedrockConfig;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
+2 -42
View File
@@ -308,45 +308,5 @@ fn tail_limited_payload_context(payload: &str, max_chars: usize) -> Value {
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{normalize_endpoint_url, sanitize_error, tail_limited_payload_context};
#[test]
fn endpoint_accepts_base_or_logs_path() {
assert_eq!(
normalize_endpoint_url("https://logging.ryserve.net").as_deref(),
Some("https://logging.ryserve.net/api/logs")
);
assert_eq!(
normalize_endpoint_url("https://logging.ryserve.net/api/logs/").as_deref(),
Some("https://logging.ryserve.net/api/logs")
);
}
#[test]
fn errors_are_compacted_truncated_and_lightly_redacted() {
let error = format!("failed\nAuthorization: Bearer sk-test {}", "x".repeat(700));
let sanitized = sanitize_error(error);
assert!(!sanitized.contains("sk-test"));
assert!(!sanitized.contains('\n'));
assert!(sanitized.chars().count() <= 501);
}
#[test]
fn raw_payload_cap_keeps_tail() {
assert_eq!(
tail_limited_payload_context("0123456789", 4),
json!({
"payload": "6789",
"payload_total_chars": 10,
"payload_included_chars": 4,
"payload_max_chars": 4,
"truncated": true,
"truncation_strategy": "tail",
})
);
}
}
#[path = "remote_logging_tests.rs"]
mod tests;
+40
View File
@@ -0,0 +1,40 @@
use serde_json::json;
use super::{normalize_endpoint_url, sanitize_error, tail_limited_payload_context};
#[test]
fn endpoint_accepts_base_or_logs_path() {
assert_eq!(
normalize_endpoint_url("https://logging.ryserve.net").as_deref(),
Some("https://logging.ryserve.net/api/logs")
);
assert_eq!(
normalize_endpoint_url("https://logging.ryserve.net/api/logs/").as_deref(),
Some("https://logging.ryserve.net/api/logs")
);
}
#[test]
fn errors_are_compacted_truncated_and_lightly_redacted() {
let error = format!("failed\nAuthorization: Bearer sk-test {}", "x".repeat(700));
let sanitized = sanitize_error(error);
assert!(!sanitized.contains("sk-test"));
assert!(!sanitized.contains('\n'));
assert!(sanitized.chars().count() <= 501);
}
#[test]
fn raw_payload_cap_keeps_tail() {
assert_eq!(
tail_limited_payload_context("0123456789", 4),
json!({
"payload": "6789",
"payload_total_chars": 10,
"payload_included_chars": 4,
"payload_max_chars": 4,
"truncated": true,
"truncation_strategy": "tail",
})
);
}
+119 -12
View File
@@ -10,11 +10,11 @@ use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::provider_run_coordinator::ProviderRunProjection;
use crate::ai::agent::runtime_activity;
use crate::ai::bedrock::response_translator::{
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
use crate::ai::provider::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
};
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub(crate) struct RuntimeResponseConfig {
@@ -51,6 +51,7 @@ pub(crate) struct RuntimeResponseTranslator {
pub(crate) struct ProviderRunResponseProjector {
translator: RuntimeResponseTranslator,
has_started_model_turn: bool,
plan_tasks_projected: bool,
finished: bool,
}
@@ -59,6 +60,7 @@ impl ProviderRunResponseProjector {
Self {
translator: RuntimeResponseTranslator::new(config),
has_started_model_turn: false,
plan_tasks_projected: false,
finished: false,
}
}
@@ -70,6 +72,7 @@ impl ProviderRunResponseProjector {
Self {
translator: RuntimeResponseTranslator::restored(config, projection_was_initialized),
has_started_model_turn: false,
plan_tasks_projected: true,
finished: false,
}
}
@@ -96,8 +99,24 @@ impl ProviderRunResponseProjector {
Ok(self.translator.discard_failed_turn_output())
}
ProviderRunProjection::ModelTurnRequested { .. }
| ProviderRunProjection::ModelTurnFinished { .. }
| ProviderRunProjection::ToolBatchReady { .. } => Ok(Vec::new()),
| ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()),
ProviderRunProjection::ToolBatchReady { batch } => {
if !self.plan_tasks_projected {
if let Some(todos) = todos_from_plan_batch(&batch) {
self.plan_tasks_projected = true;
return Ok(vec![build_todo_update(
&self.translator.config.task_id,
api::message::update_todos::Operation::CreateTodoList(
api::CreateTodoList {
initial_todos: todos,
},
),
)]);
}
}
// Tool batches indicate runtime activity, not completion of planned work.
Ok(Vec::new())
}
}
}
@@ -118,9 +137,11 @@ impl ProviderRunResponseProjector {
ProviderRunOutcome::Completed(completion) => Ok(self
.translator
.finish_provider_run(completion.stop_reason.clone(), aggregate_usage)),
ProviderRunOutcome::Failed(failure) => Ok(self
.translator
.provider_failure(&failure.message, aggregate_usage)),
ProviderRunOutcome::Failed(failure) => Ok(self.translator.provider_failure(
&failure.message,
failure.source.as_ref(),
aggregate_usage,
)),
ProviderRunOutcome::Cancelled { .. } => Ok(self
.translator
.finish_provider_run(StopReason::Cancelled, aggregate_usage)),
@@ -128,6 +149,80 @@ impl ProviderRunResponseProjector {
}
}
// Only explicit multi-step checklists in a plan become tasks. Other document bullets
// (risks, examples, requirements) are not executable tasks.
fn todos_from_plan_batch(
batch: &galaxy_agent_core::PendingToolBatch,
) -> Option<Vec<api::TodoItem>> {
let plan_call = batch.calls.iter().find(|pending| {
matches!(
pending.call.name.as_str(),
"create_plan" | "create_documents"
)
})?;
let content = plan_call
.call
.arguments
.get("documents")?
.as_array()?
.first()?
.get("content")?
.as_str()?;
let mut lines = content.lines();
lines.find(|line| matches!(line.trim(), "## Tasks" | "## Implementation Tasks"))?;
let todos = lines
.take_while(|line| !line.trim().starts_with("## "))
.filter_map(|line| line.trim().strip_prefix("- [ ]").map(str::trim))
.filter(|item| !item.is_empty())
.take(50)
.enumerate()
.map(|(index, item)| api::TodoItem {
id: format!("plan-{}-{index}", plan_call.call.id),
title: item
.split_once(" - ")
.map_or(item, |(title, _)| title)
.trim()
.to_owned(),
description: item.to_owned(),
})
.collect::<Vec<_>>();
(todos.len() > 1).then_some(todos)
}
fn build_todo_update(
task_id: &str,
operation: api::message::update_todos::Operation,
) -> ResponseEvent {
let message = api::Message {
id: Uuid::new_v4().to_string(),
task_id: task_id.to_owned(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: Vec::new(),
fetched_memories: Vec::new(),
message: Some(api::message::Message::UpdateTodos(
api::message::UpdateTodos {
operation: Some(operation),
},
)),
};
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![api::ClientAction {
action: Some(api::client_action::Action::AddMessagesToTask(
api::client_action::AddMessagesToTask {
task_id: task_id.to_owned(),
messages: vec![message],
},
)),
}],
},
)),
}
}
impl RuntimeResponseTranslator {
pub(crate) fn new(config: RuntimeResponseConfig) -> Self {
Self::with_initialization(config, false)
@@ -421,15 +516,27 @@ impl RuntimeResponseTranslator {
self.finished_with_reason(map_stop_reason(reason))
}
fn provider_failure(&mut self, message: &str, aggregate_usage: &Usage) -> Vec<ResponseEvent> {
fn provider_failure(
&mut self,
message: &str,
source: Option<&galaxy_agent_core::AgentError>,
aggregate_usage: &Usage,
) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
events.push(self.finished_with_usage(
let reason = if source
.is_some_and(|error| error.kind == galaxy_agent_core::AgentErrorKind::Authentication)
{
stream_finished::Reason::InvalidApiKey(stream_finished::InvalidApiKey {
provider: warp_multi_agent_api::LlmProvider::AwsBedrock as i32,
model_name: self.config.model_id.clone(),
})
} else {
stream_finished::Reason::InternalError(stream_finished::InternalError {
message: message.to_owned(),
}),
aggregate_usage,
));
})
};
events.push(self.finished_with_usage(reason, aggregate_usage));
events
}
@@ -82,6 +82,69 @@ fn restored_provider_projection_skips_stream_initialization() {
));
}
#[test]
fn provider_activity_does_not_create_or_complete_task_lists() {
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
task_id: "task".to_owned(),
conversation_id: "conversation".to_owned(),
needs_create_task: false,
user_query: None,
model_id: "model".to_owned(),
max_context_tokens: Some(1_000),
capabilities: RuntimeCapabilities::provider(),
empty_output_message: None,
});
let work_id = galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(1),
};
let mut events = projector
.project(ProviderRunProjection::ModelTurnStarted {
work_id: work_id.clone(),
profile: galaxy_agent_core::ProviderRequestProfile::new("base"),
runtime_id: "runtime".to_owned(),
model_id: "model".to_owned(),
runtime_request_id: "request".to_owned(),
retry_attempt: 0,
elapsed_ms: 1,
})
.unwrap();
assert_eq!(events.len(), 1);
let tool_events = projector
.project(ProviderRunProjection::ToolBatchReady {
batch: galaxy_agent_core::PendingToolBatch {
work_id,
calls: Vec::new(),
},
})
.unwrap();
assert!(tool_events.is_empty());
events.extend(
projector
.finish(
&galaxy_agent_core::ProviderRunOutcome::Completed(
galaxy_agent_core::ProviderRunCompletion {
stop_reason: StopReason::Completed,
},
),
&Usage::default(),
)
.unwrap(),
);
for event in events {
if let Some(response_event::Type::ClientActions(actions)) = event.r#type {
for action in actions.actions {
if let Some(client_action::Action::AddMessagesToTask(add)) = action.action {
assert!(add.messages.iter().all(|message| !matches!(
message.message,
Some(message::Message::UpdateTodos(_))
)));
}
}
}
}
}
#[test]
fn restored_uninitialized_projection_replays_init_before_live_delta() {
let config = RuntimeResponseConfig {
@@ -545,3 +608,35 @@ fn capabilities_reject_events_owned_by_the_other_runtime_shape() {
})
.is_err());
}
#[test]
fn plan_tasks_require_multiple_explicit_checklist_items_in_a_task_section() {
let batch = |content: &str| galaxy_agent_core::PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(1),
},
calls: vec![galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: "plan-call".to_string(),
name: "create_plan".to_string(),
arguments: serde_json::json!({"documents": [{"content": content}]}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
}],
};
for content in [
"## Risks\n- [ ] Risk one\n- [ ] Risk two",
"## Tasks\n- [ ] Update the parser",
"## Tasks\n- Requirement one\n- Requirement two",
] {
assert!(super::todos_from_plan_batch(&batch(content)).is_none());
}
let todos = super::todos_from_plan_batch(&batch(
"## Tasks\n- [ ] Add retry correlation\n- [ ] Test stale callbacks\n## Risks\n- [ ] Slow shell startup",
)).unwrap();
assert_eq!(todos.len(), 2);
assert_eq!(todos[0].title, "Add retry correlation");
assert_eq!(todos[1].title, "Test stale callbacks");
assert_ne!(todos[0].id, todos[1].id);
}
@@ -82,6 +82,7 @@ pub(crate) enum ProviderRunProjection {
pub(crate) enum ProviderRunBlock {
Tools(PendingToolBatch),
ReadyToCallModel,
ContextWindowExceeded,
AwaitingDriver {
work_id: ExternalWorkId,
stop_reason: StopReason,
@@ -175,6 +176,8 @@ pub(crate) struct ProviderRunCoordinator {
profiles: BTreeMap<String, ProviderRunProfile>,
model_start_timeout: Duration,
model_event_idle_timeout: Duration,
context_compaction_requested: bool,
max_context_tokens: Option<u32>,
}
impl ProviderRunCoordinator {
@@ -218,6 +221,8 @@ impl ProviderRunCoordinator {
profiles,
model_start_timeout: PROVIDER_MODEL_START_TIMEOUT,
model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT,
context_compaction_requested: false,
max_context_tokens: None,
})
}
@@ -239,6 +244,10 @@ impl ProviderRunCoordinator {
self.profiles.get(profile).map(|profile| &profile.request)
}
pub(crate) fn set_max_context_tokens(&mut self, max_context_tokens: Option<u32>) {
self.max_context_tokens = max_context_tokens;
}
pub(crate) fn insert_profile(
&mut self,
profile: impl Into<String>,
@@ -364,8 +373,16 @@ impl ProviderRunCoordinator {
if !self.checkpoint_or_fail(&mut checkpoint).await? {
return self.terminal_block();
}
if self.request_needs_context_compaction(&call) {
self.run.prepare_context_compaction(&call.work_id)?;
return Ok(ProviderRunBlock::ContextWindowExceeded);
}
self.drive_model_call_acknowledged(call, control.clone(), &mut project)
.await?;
if self.context_compaction_requested {
self.context_compaction_requested = false;
return Ok(ProviderRunBlock::ContextWindowExceeded);
}
}
Some(ProviderRunStep::DispatchTools(batch)) => {
if !self.checkpoint_or_fail(&mut checkpoint).await? {
@@ -428,6 +445,17 @@ impl ProviderRunCoordinator {
}
}
fn request_needs_context_compaction(&self, call: &ProviderModelCall) -> bool {
let Some(max_context_tokens) = self.max_context_tokens else {
return false;
};
let Some(profile) = self.profiles.get(call.profile.as_str()) else {
return false;
};
let request = request_for_model_call(profile.request.clone(), call);
estimate_turn_request_tokens(&request) >= u64::from(max_context_tokens)
}
async fn checkpoint_or_fail<C>(
&mut self,
checkpoint: &mut C,
@@ -830,6 +858,11 @@ impl ProviderRunCoordinator {
self.run.cancel("provider model call was cancelled")?;
return Ok(());
}
if reason == StopReason::ContextWindowExceeded {
self.run.prepare_context_compaction(&call.work_id)?;
self.context_compaction_requested = true;
return Ok(());
}
let turn = buffer.complete(reason, advertised_tools);
if let Err(error) = self.run.accept_model_turn(&call.work_id, turn) {
self.run.fail(
@@ -925,6 +958,11 @@ impl ProviderRunCoordinator {
"payload": &error,
}));
let error_message = error.message.clone();
if error.kind == AgentErrorKind::ContextWindowExceeded {
self.run.prepare_context_compaction(&call.work_id)?;
self.context_compaction_requested = true;
return Ok(());
}
let disposition = self
.run
.register_model_failure(&call.work_id, error.clone())?;
@@ -1082,6 +1120,44 @@ fn request_for_model_call(mut template: TurnRequest, call: &ProviderModelCall) -
template
}
const ESTIMATED_CHARS_PER_TOKEN: u64 = 4;
/// Deliberately overestimates request size without requiring provider-specific tokenizers.
fn estimate_turn_request_tokens(request: &TurnRequest) -> u64 {
let mut chars = request
.system_prompt
.as_deref()
.map_or(0, |text| text.chars().count());
chars += request.prompt.as_ref().map_or(0, |prompt| {
serde_json::to_string(prompt)
.unwrap_or_default()
.chars()
.count()
});
chars += request
.messages
.iter()
.map(|message| {
serde_json::to_string(message)
.unwrap_or_default()
.chars()
.count()
})
.sum::<usize>();
chars += request
.tools
.iter()
.map(|tool| {
serde_json::to_string(tool)
.unwrap_or_default()
.chars()
.count()
})
.sum::<usize>();
let input_tokens = (chars as u64).div_ceil(ESTIMATED_CHARS_PER_TOKEN);
input_tokens.saturating_add(request.max_output_tokens.unwrap_or_default())
}
fn tool_event_call_id(event: &ToolEvent) -> Result<&str, ProviderRunCoordinatorError> {
match event {
ToolEvent::Proposed { call } => Ok(&call.id),
@@ -164,6 +164,48 @@ fn request() -> TurnRequest {
request
}
#[test]
fn estimates_turn_request_with_system_tools_messages_and_output() {
let mut request = TurnRequest::new(
"test-model",
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("12345678".to_string()),
}],
);
request.system_prompt = Some("1234".to_string());
request.tools = vec![ToolDefinition {
name: "tool".to_string(),
description: "description".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}];
request.max_output_tokens = Some(10);
let expected_input = (request.system_prompt.as_deref().unwrap().len()
+ serde_json::to_string(&request.messages[0]).unwrap().len()
+ serde_json::to_string(&request.tools[0]).unwrap().len()) as u64;
assert_eq!(
estimate_turn_request_tokens(&request),
expected_input.div_ceil(ESTIMATED_CHARS_PER_TOKEN) + 10
);
}
#[tokio::test]
async fn proactively_compacts_before_starting_an_oversized_request() {
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
let mut coordinator = coordinator(runtime.clone());
coordinator.set_max_context_tokens(Some(1));
let (_sender, control) = turn_control();
let block = coordinator
.drive_until_blocked(control, |_| Ok(()))
.await
.unwrap();
assert_eq!(block, ProviderRunBlock::ContextWindowExceeded);
assert!(runtime.requests().is_empty());
}
fn started(id: &str) -> ScriptedEvent {
Ok(AgentEvent::TurnStarted {
runtime_request_id: id.to_string(),
@@ -692,7 +734,7 @@ async fn execution_failure_commits_one_correlated_error_result() {
}
#[tokio::test]
async fn inline_tool_batches_continue_without_leaving_the_coordinator() {
async fn inline_tool_batches_commit_before_the_next_model_boundary() {
let recall_turn = Ok(vec![
started("request-recall"),
Ok(AgentEvent::Tool {
@@ -717,6 +759,13 @@ async fn inline_tool_batches_continue_without_leaving_the_coordinator() {
.await
.unwrap();
assert!(matches!(block, ProviderRunBlock::ReadyToCallModel));
assert_eq!(runtime.requests().len(), 1);
let (_sender, control) = turn_control();
let block = coordinator
.drive_until_blocked(control, collect_projection(&mut projections))
.await
.unwrap();
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
assert_eq!(runtime.requests().len(), 2);
assert!(projections.iter().any(|projection| matches!(
+3 -3
View File
@@ -21,9 +21,9 @@ use super::rig_tool::action_from_tool_call;
use super::ProviderRunProfile;
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::AIAgentAction;
use crate::ai::bedrock::client::BedrockClient;
use crate::ai::bedrock::convert::CachingConfig;
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
use crate::ai::provider::client::BedrockClient;
use crate::ai::provider::convert::CachingConfig;
use crate::ai::provider::external_config::ExternalBedrockConfig;
use crate::ai::provider::types::ConversationMessage;
use crate::ai::runtime::RuntimeResponseConfig;
use crate::settings::OpenAIProviderKind;
+16 -6
View File
@@ -11,15 +11,16 @@ use galaxy_agent_core::{
};
use sha2::{Digest as _, Sha256};
use uuid::Uuid;
use warp_multi_agent_api as api;
use warp_multi_agent_api::ToolType;
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode};
use crate::ai::bedrock::request_translator::{
default_tool_definitions, sanitize_messages_for_bedrock, tool_name_is_supported,
};
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::openai::request_translator::sanitize_messages_for_openai;
use crate::ai::provider::request_translator::{
default_tool_definitions, sanitize_messages_for_bedrock, tool_name_is_supported,
};
use crate::ai::provider::types::flatten_tool_history_for_no_tools_turn;
pub(crate) struct PreparedRigTurn {
@@ -730,6 +731,9 @@ fn build_system_prompt(
prompt.push_str(
"## Execution Contract\nContinue until the user's requested outcome is complete and validated. Do not stop at an intermediate analysis, plan, status update, or promise of future work, and do not ask the user to say \"continue\". After each tool result, choose and perform the next necessary step. Stop only when the request is fulfilled or a concrete blocker requires user input; identify that blocker explicitly.\n\n",
);
prompt.push_str(
"## Task Tracking\nUse task tracking only for substantial work with multiple concrete steps, after planning has established those steps or when the user provides an actionable plan. Skip it for simple requests, questions, and exploratory planning. Each task must name a specific change or deliverable with a clear completion condition. Do not create generic workflow items such as 'research the request', 'critique the plan', 'execute the plan', or 'verify the result'. Update tasks only when their actual work is complete; a tool call or model turn alone is not evidence of completion.\n\n",
);
let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten();
let mut environment = Vec::new();
let mut request_time = None;
@@ -892,7 +896,7 @@ fn build_system_prompt(
match mode {
RigRequestMode::Normal => {}
RigRequestMode::Plan => prompt.push_str(
"## Plan Mode\nInspect and produce an implementation-ready plan. Do not edit files or perform state-changing actions. Research as needed, then finish by calling `create_plan` to write the plan with the built-in planning tools. If a plan document already exists for this task, call `edit_plan` instead. Do not return the plan only as prose, and do not claim completion until the plan tool succeeds.\n\n",
"## Plan Mode\nInspect and produce an implementation-ready plan. Do not edit files or perform state-changing actions. Research as needed, then finish by calling `create_plan` to write the plan with the built-in planning tools. For multi-step implementation work, include a concise `## Tasks` section in the document with short, specific `- [ ]` items. If a plan document already exists for this task, call `edit_plan` instead. Do not return the plan only as prose, and do not claim completion until the plan tool succeeds. Once the plan is created, avoid asking follow-up questions unless you are genuinely blocked or materially uncertain about the next step.\n\n",
),
RigRequestMode::Orchestrate => prompt.push_str(
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
@@ -917,19 +921,25 @@ fn build_system_prompt(
.join(", "),
);
prompt.push_str(".\nNever invent tool names or parameters. Check command exit codes and tool error results before claiming success.\n");
let has_search_codebase = tools.iter().any(|tool| tool.name == "search_codebase");
if has_search_codebase {
prompt.push_str(
"For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context.\n",
);
}
if tools.iter().any(|tool| tool.name == "run_shell_command") {
let has_file_tools = tools
.iter()
.any(|tool| matches!(tool.name.as_str(), "file_glob" | "grep" | "read_files"));
if has_file_tools {
prompt.push_str(
"Prefer `file_glob`, `grep`, and `read_files` for file discovery, content search, and file reading when they are available. Reserve `run_shell_command` for operations those specialized tools cannot perform; do not use shell `find`, `grep`, `rg`, `cat`, `head`, or `tail` as substitutes.\n",
"Prefer `file_glob` for filenames and `read_files` for focused content when they are available. Reserve `run_shell_command` for operations specialized tools cannot perform; do not use shell `find`, `grep`, `rg`, `cat`, `head`, or `tail` as substitutes.\n",
);
}
}
if tools.iter().any(|tool| tool.name == "create_plan") {
prompt.push_str(
"Plan document creation is available through `create_plan`. When the user asks you to create a plan for review, research first as needed, then call `create_plan`; do not merely return the plan as prose or claim that no plan-creation tool is available. If the user asks to review the plan before implementation, creating the document and presenting it for review is the requested outcome; do not implement it until they approve.\n",
"Plan document creation is available through `create_plan`. When the user asks you to create a plan for review, research first as needed, then call `create_plan`; do not merely return the plan as prose or claim that no plan-creation tool is available. For multi-step implementation work, include a concise `## Tasks` section with short, specific `- [ ]` items so the user can track progress. If the plan structure changes materially, invalidate it and create a replacement; status changes should update the existing task list instead. If the user asks to review the plan before implementation, creating the document and presenting it for review is the requested outcome; do not implement it until they approve. Once a plan and task list exist, continue working against them and avoid follow-up questions unless genuinely blocked or materially uncertain.\n",
);
}
}
+13 -5
View File
@@ -32,7 +32,6 @@ fn config() -> OpenAIClientConfig {
reasoning_effort: None,
max_input_tokens: Some(128_000),
max_output_tokens: Some(8_192),
use_rig: true,
supports_system_messages: true,
}
}
@@ -154,15 +153,18 @@ fn builds_a_rig_turn_directly_from_galaxy_request_state() {
}
#[test]
fn system_prompt_prefers_specialized_file_tools_over_shell_substitutes() {
fn system_prompt_prioritizes_semantic_code_search_over_regex_search() {
let mut params = RequestParams::new_for_test();
params.input = vec![user_query("Find every Rust file containing ProviderRun")];
params.input = vec![user_query(
"Find the implementation that handles ProviderRun",
)];
let prepared = prepare_rig_turn(
&config(),
params,
vec![
ToolType::RunShellCommand,
ToolType::SearchCodebase,
ToolType::FileGlob,
ToolType::Grep,
ToolType::ReadFiles,
@@ -171,7 +173,11 @@ fn system_prompt_prefers_specialized_file_tools_over_shell_substitutes() {
);
let prompt = prepared.request.system_prompt.expect("system prompt");
assert!(prompt.contains("Prefer `file_glob`, `grep`, and `read_files`"));
assert!(prompt.contains("MUST use `search_codebase` first"));
assert!(prompt.contains("Never use `grep` to discover or search source code"));
assert!(
prompt.contains("Prefer `file_glob` for filenames and `read_files` for focused content")
);
assert!(prompt.contains("do not use shell `find`, `grep`, `rg`, `cat`, `head`, or `tail`"));
}
@@ -511,7 +517,9 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
let prompt = prepared.request.system_prompt.expect("system prompt");
assert!(prompt.contains("## Running Command Monitor"));
assert!(prompt.contains("`read_shell_command_output` with a short delay"));
assert!(
prompt.contains("Use the command ID from the tool result for every read/write operation")
);
assert!(prompt.contains("one concise, user-facing sentence"));
assert!(prompt.contains("Do not send a text-only progress response"));
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
-1
View File
@@ -16,7 +16,6 @@ fn openai_config(model: &str) -> OpenAIClientConfig {
reasoning_effort: None,
max_input_tokens: Some(128_000),
max_output_tokens: Some(8_192),
use_rig: true,
supports_system_messages: true,
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ pub(crate) fn action_from_tool_call(
optional_bounded_u64(
input,
"wait_seconds",
crate::ai::bedrock::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS,
crate::ai::provider::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS,
)?
.unwrap_or(2),
))),
+2 -14
View File
@@ -30,17 +30,5 @@ macro_rules! tool_debug {
pub(crate) use tool_debug;
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use super::env_value_is_enabled;
#[test]
fn diagnostic_env_accepts_only_explicit_true_values() {
assert!(env_value_is_enabled(Some(OsStr::new("1"))));
assert!(env_value_is_enabled(Some(OsStr::new("TRUE"))));
assert!(!env_value_is_enabled(Some(OsStr::new("0"))));
assert!(!env_value_is_enabled(Some(OsStr::new("yes"))));
assert!(!env_value_is_enabled(None));
}
}
#[path = "tool_diagnostics_tests.rs"]
mod tests;
+12
View File
@@ -0,0 +1,12 @@
use std::ffi::OsStr;
use super::env_value_is_enabled;
#[test]
fn diagnostic_env_accepts_only_explicit_true_values() {
assert!(env_value_is_enabled(Some(OsStr::new("1"))));
assert!(env_value_is_enabled(Some(OsStr::new("TRUE"))));
assert!(!env_value_is_enabled(Some(OsStr::new("0"))));
assert!(!env_value_is_enabled(Some(OsStr::new("yes"))));
assert!(!env_value_is_enabled(None));
}
@@ -9,7 +9,7 @@ use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
MainAxisAlignment, MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Shrinkable, Stack,
ParentOffsetBounds, Shrinkable, Stack, Text,
};
use galaxyui::{Element, ViewHandle};
use pathfinder_geometry::vector::vec2f;
@@ -40,6 +40,43 @@ impl CodeReviewHeader {
right_section.add_child(git_button);
}
if code_review_header_fields.conflict_count > 0 {
right_section.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
)
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
if let Some(nav_button) = &code_review_header_fields.file_nav_button {
right_section.add_child(Self::render_file_nav_button(nav_button));
}
+73 -7
View File
@@ -5,7 +5,7 @@ use galaxyui::elements::{
Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch,
Stack,
Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
@@ -87,6 +87,7 @@ impl CodeReviewHeader {
code_review_header_fields: &CodeReviewHeaderFields,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut left_section_wide = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
@@ -113,8 +114,40 @@ impl CodeReviewHeader {
let mut right_section_wide = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if code_review_header_fields.conflict_count > 0 {
right_section_wide.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
right_section_wide
.add_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
let has_no_changes = state.to_diff_stats().has_no_changes();
@@ -169,6 +202,7 @@ impl CodeReviewHeader {
code_review_header_fields: &CodeReviewHeaderFields,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut left_section_compact = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
@@ -228,12 +262,44 @@ impl CodeReviewHeader {
));
}
let right_section_compact = Flex::row()
let mut right_section_compact = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish())
.with_child(Container::new(right_subsection_compact.finish()).finish());
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if code_review_header_fields.conflict_count > 0 {
right_section_compact.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
right_section_compact
.add_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
right_section_compact.add_child(Container::new(right_subsection_compact.finish()).finish());
Clipped::new(
Shrinkable::new(
+252 -3
View File
@@ -20,7 +20,7 @@ use pathfinder_geometry::vector::{vec2f, Vector2F};
use rand::distributions::Alphanumeric;
use rand::Rng;
use string_offset::CharOffset;
use vec1::Vec1;
use vec1::{vec1, Vec1};
use warp_editor::content::buffer::{AutoScrollBehavior, InitialBufferState, SelectionOffsets};
use warp_editor::model::CoreEditorModel;
use warp_editor::render::element::VerticalExpansionBehavior;
@@ -104,6 +104,7 @@ use crate::code_review::find_model::CodeReviewFindModel;
use crate::code_review::git_repo_model::{GitRepoModels, GitRepoStatusEvent, GitRepoStatusModel};
use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel};
use crate::code_review::hidden_lines::calculate_hidden_lines;
use crate::code_review::merge_conflicts::{parse_conflicts, resolve_conflict, ConflictResolution};
#[cfg(feature = "local_fs")]
use crate::code_review::telemetry_event::DiffSetContextScope;
use crate::code_review::telemetry_event::{
@@ -160,6 +161,8 @@ pub struct CodeReviewHeaderFields {
pub diff_state_model: ModelHandle<DiffStateModel>,
pub maximize_button: ViewHandle<ActionButton>,
pub diff_selector: ViewHandle<DiffSelector>,
pub conflict_filter_button: ViewHandle<ActionButton>,
pub conflict_count: usize,
pub header_menu: ViewHandle<Menu<CodeReviewAction>>,
pub header_menu_open: bool,
pub header_dropdown_button: ViewHandle<ActionButton>,
@@ -353,6 +356,12 @@ pub enum CodeReviewAction {
ViewPr(String),
PublishBranch,
SubmitReviewComments,
ToggleConflictsOnly,
ResolveConflict {
path: String,
conflict_index: usize,
resolution: ConflictResolution,
},
}
pub struct FileState {
@@ -366,6 +375,10 @@ pub struct FileState {
discard_button: ViewHandle<ActionButton>,
add_context_button: ViewHandle<ActionButton>,
copy_path_button: ViewHandle<ActionButton>,
/// Stable actions for the next unresolved conflict block. The live editor
/// is reparsed before each action, so edits remain safe after typing or
/// resolving another block.
conflict_action_buttons: Option<[ViewHandle<ActionButton>; 4]>,
}
pub(crate) struct LoadedState {
@@ -612,6 +625,11 @@ pub trait ReviewActionTargetProvider {
/// State shared among the entire code review view.
pub struct CodeReviewView {
active_repo: Option<RepositoryState>,
conflict_filter_button: ViewHandle<ActionButton>,
/// When enabled, the file navigator shows only files with unresolved
/// conflict markers. This keeps merge work front and center without
/// changing the selected diff set.
conflicts_only: bool,
focus_handle: Option<PaneFocusHandle>,
maximize_button: ViewHandle<ActionButton>,
@@ -1165,6 +1183,13 @@ impl CodeReviewView {
.on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleFileSidebar))
});
let conflict_filter_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Conflicts only", SecondaryTheme)
.with_size(ButtonSize::Small)
.with_tooltip("Show only files with unresolved merge conflicts")
.on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleConflictsOnly))
});
let git_primary_action_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Commit", SecondaryTheme)
.with_size(ButtonSize::Small)
@@ -1320,6 +1345,8 @@ impl CodeReviewView {
let mut view = Self {
active_repo,
conflict_filter_button,
conflicts_only: false,
ui_state_handles,
diff_state_model,
focus_handle: None,
@@ -2703,6 +2730,56 @@ impl CodeReviewView {
})
});
let conflict_action_buttons = if file.file_diff.status.is_conflicted()
&& file.file_diff.conflict_count > 0
&& FeatureFlag::CodeReviewSaveChanges.is_enabled()
{
let conflict_path = file.file_diff.file_path.clone();
Some(
[
("Left", ConflictResolution::Ours),
("Right", ConflictResolution::Theirs),
("Both", ConflictResolution::Both),
("Smart", ConflictResolution::Smart),
]
.map(|(label, resolution)| {
let path = conflict_path.clone();
ctx.add_typed_action_view(move |_ctx| {
ActionButton::new(label, NakedTheme)
.with_size(ButtonSize::InlineActionHeader)
.with_tooltip(match resolution {
ConflictResolution::Ours => {
"Take left / ours for the next conflict"
}
ConflictResolution::Theirs => {
"Take right / theirs for the next conflict"
}
ConflictResolution::Both => {
"Keep both sides for the next conflict"
}
ConflictResolution::Smart => {
"Smart merge the next conflict using the common base"
}
})
.on_click({
let path = path.clone();
move |ctx| {
ctx.dispatch_typed_action(
CodeReviewAction::ResolveConflict {
path: path.clone(),
conflict_index: 0,
resolution,
},
)
}
})
})
}),
)
} else {
None
};
file_states.push(FileState {
file_diff: file.file_diff.clone(),
editor_state,
@@ -2712,6 +2789,7 @@ impl CodeReviewView {
discard_button,
add_context_button,
copy_path_button,
conflict_action_buttons,
sidebar_mouse_state: MouseStateHandle::default(),
header_mouse_state: MouseStateHandle::default(),
})
@@ -2739,7 +2817,6 @@ impl CodeReviewView {
let Some((_, file_state)) = diff_state.get_index(index) else {
return Empty::new().finish();
};
self.render_file_diff(file_state, index, scroll_offset, appearance, app)
}
@@ -2876,6 +2953,20 @@ impl CodeReviewView {
Some(&mut self.active_repo.as_mut()?.state)
}
fn loaded_state(&self) -> Option<&LoadedState> {
match self.state() {
CodeReviewViewState::Loaded(state) => Some(state),
_ => None,
}
}
fn loaded_state_mut(&mut self) -> Option<&mut LoadedState> {
match self.state_mut()? {
CodeReviewViewState::Loaded(state) => Some(state),
_ => None,
}
}
#[cfg(not(target_family = "wasm"))]
fn session_env(&self, app: &AppContext) -> Option<GitSessionState> {
let terminal_view = self.focused_terminal(app)?;
@@ -4189,6 +4280,12 @@ impl CodeReviewView {
is_in_split_pane,
maximize_button: self.maximize_button.clone(),
diff_selector: self.diff_selector.clone(),
conflict_filter_button: self.conflict_filter_button.clone(),
conflict_count: state
.file_states
.values()
.map(|file| file.file_diff.conflict_count)
.sum(),
header_menu: self.header_menu.clone(),
header_menu_open: self.header_menu_open,
diff_state_model: self.diff_state_model.clone(),
@@ -4562,7 +4659,16 @@ impl CodeReviewView {
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
for (file_index, file_state) in state.file_states.values().enumerate() {
let visible_files = state
.file_states
.values()
.enumerate()
.filter(|(_, file_state)| {
!self.conflicts_only || file_state.file_diff.conflict_count > 0
})
.collect::<Vec<_>>();
for (file_index, file_state) in visible_files {
let file_row = self.render_file_sidebar_row(file_state, appearance);
column.add_child(
Hoverable::new(file_state.sidebar_mouse_state.clone(), |mouse_state| {
@@ -4985,6 +5091,36 @@ impl CodeReviewView {
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if file.file_diff.conflict_count > 0 {
if let Some(conflict_action_buttons) = &file.conflict_action_buttons {
right_row.add_child(
Container::new(
Text::new(
"Next conflict",
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(4.)
.finish(),
);
for button in conflict_action_buttons {
right_row.add_child(
EventHandler::new(
Container::new(ChildView::new(button).finish())
.with_margin_left(4.)
.finish(),
)
.on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation)
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish(),
);
}
}
}
// Add file diff as context button (before remove button)
if FeatureFlag::DiffSetAsContext.is_enabled() {
right_row.add_child(
@@ -5666,6 +5802,7 @@ impl CodeReviewView {
);
}
CodeEditorEvent::ContentChanged { origin, .. } if origin.from_user() => {
self.update_conflict_count_for_file(&file_path, &editor, ctx);
if let Some((view_handle, content_version)) = self.last_revert.take() {
let same_content_version = content_version == editor.as_ref(ctx).version(ctx);
@@ -6248,6 +6385,100 @@ impl CodeReviewView {
diff_lines.join("\n")
}
fn update_conflict_count_for_file(
&mut self,
path: &str,
editor: &ViewHandle<CodeEditorView>,
ctx: &mut ViewContext<Self>,
) {
let should_track = self
.loaded_state()
.and_then(|state| state.file_states.get(path))
.is_some_and(|file| file.file_diff.status.is_conflicted());
if !should_track {
return;
}
let count = parse_conflicts(&editor.as_ref(ctx).text(ctx).into_string()).len();
if let Some(state) = self.loaded_state_mut() {
if let Some(file) = state.file_states.get_mut(path) {
if file.file_diff.conflict_count != count {
file.file_diff.conflict_count = count;
ctx.notify();
}
}
}
if self.loaded_state().is_some_and(|state| {
state
.file_states
.iter()
.all(|(_, file)| file.file_diff.conflict_count == 0)
}) && self.conflicts_only
{
self.conflicts_only = false;
self.conflict_filter_button.update(ctx, |button, ctx| {
button.set_active(false, ctx);
});
ctx.notify();
}
}
fn resolve_conflict_for_file(
&mut self,
path: &str,
conflict_index: usize,
resolution: ConflictResolution,
ctx: &mut ViewContext<Self>,
) {
let Some(editor) = self
.loaded_state()
.and_then(|state| state.file_states.get(path))
.and_then(|file| file.editor_state.as_ref())
.map(|editor_state| editor_state.editor.clone())
else {
return;
};
let current_text = editor
.as_ref(ctx)
.editor()
.as_ref(ctx)
.text(ctx)
.into_string();
let Some((byte_range, replacement)) =
resolve_conflict(&current_text, conflict_index, resolution)
else {
return;
};
let mut resolved_text = current_text.clone();
resolved_text.replace_range(byte_range.clone(), &replacement);
let remaining_conflicts = parse_conflicts(&resolved_text).len();
editor.update(ctx, |local_editor, ctx| {
let start = CharOffset::from(current_text[..byte_range.start].chars().count());
let end = CharOffset::from(current_text[..byte_range.end].chars().count());
local_editor.editor().update(ctx, |editor, ctx| {
editor.apply_edits(vec1![(replacement, start..end)], ctx);
});
});
if let Some(state) = self.loaded_state_mut() {
if let Some(file) = state.file_states.get_mut(path) {
file.file_diff.conflict_count = remaining_conflicts;
if remaining_conflicts == 0 {
file.conflict_action_buttons = None;
}
}
}
if let Some(index) = self
.loaded_state()
.and_then(|state| state.file_states.get_index_of(path))
{
self.viewported_list_state
.invalidate_height_for_index(index);
}
ctx.notify();
}
fn save_files(&mut self, paths: &[String], ctx: &mut ViewContext<Self>) {
for path in paths {
self.save_file(path, ctx);
@@ -7200,6 +7431,24 @@ impl TypedActionView for CodeReviewView {
CodeReviewAction::SetDiffMode(mode) => {
self.apply_diff_mode(mode.clone(), ctx);
}
CodeReviewAction::ToggleConflictsOnly => {
if !self.file_sidebar_expanded {
self.open_file_sidebar(ctx);
self.update_file_nav_button_tooltip(ctx);
}
self.conflicts_only = !self.conflicts_only;
self.conflict_filter_button.update(ctx, |button, ctx| {
button.set_active(self.conflicts_only, ctx);
});
ctx.notify();
}
CodeReviewAction::ResolveConflict {
path,
conflict_index,
resolution,
} => {
self.resolve_conflict_for_file(path, *conflict_index, *resolution, ctx);
}
CodeReviewAction::ToggleFileSidebar => {
if self.file_sidebar_expanded {
self.file_sidebar_expanded = false;
@@ -31,6 +31,7 @@ use crate::code_review::diff_size_limits::DiffSize;
use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus};
use crate::code_review::editor_state::CodeReviewEditorState;
use crate::code_review::git_repo_model::GitRepoModels;
use crate::code_review::merge_conflicts::{parse_conflicts, resolve_conflict, ConflictResolution};
use crate::code_review::GlobalCodeReviewModel;
use crate::pane_group::WorkingDirectoriesModel;
use crate::server::server_api::team::MockTeamClient;
@@ -321,6 +322,7 @@ fn create_loaded_state_with_editors(
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
},
editor_state: Some(CodeReviewEditorState::new_loaded(editor)),
@@ -332,6 +334,7 @@ fn create_loaded_state_with_editors(
discard_button,
add_context_button,
copy_path_button,
conflict_action_buttons: None,
};
(file_path, state)
})
@@ -345,6 +348,16 @@ fn create_loaded_state_with_editors(
}
}
#[test]
fn test_conflict_resolution_helpers_are_safe_for_review_edits() {
let text = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\nafter\n";
assert_eq!(parse_conflicts(text).len(), 1);
let (range, replacement) = resolve_conflict(text, 0, ConflictResolution::Both).unwrap();
let mut resolved = text.to_string();
resolved.replace_range(range, &replacement);
assert_eq!(resolved, "before\nours\ntheirs\nafter\n");
}
#[test]
fn test_relocate_comments_empty_input() {
App::test((), |mut app| async move {
+15 -5
View File
@@ -1,6 +1,7 @@
//! Overlay menu for the code review diff selector: pinned search input and
//! a filtered list of label-only rows with a left check slot.
use std::cmp;
use std::sync::Arc;
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use galaxy_core::ui::theme::Fill;
@@ -85,6 +86,9 @@ pub struct CodeReviewDiffMenu {
/// current filter. Original target order is preserved; the match result
/// carries indices for bolding matched characters in the label.
filtered: Vec<(usize, Option<FuzzyMatchResult>)>,
/// Cached render rows. The Arc lets each virtualized list closure retain
/// stable data without cloning every target on every frame.
filtered_snapshot: Arc<Vec<(DiffTarget, Option<FuzzyMatchResult>)>>,
/// Index into `filtered` of the keyboard-focused row.
selected_index: Option<usize>,
search_input: ViewHandle<EditorView>,
@@ -135,6 +139,7 @@ impl CodeReviewDiffMenu {
Self {
targets: Vec::new(),
filtered: Vec::new(),
filtered_snapshot: Arc::new(Vec::new()),
selected_index: None,
search_input,
search_query: String::new(),
@@ -145,6 +150,9 @@ impl CodeReviewDiffMenu {
/// Replace the row set and reset filter/scroll to the top.
pub fn set_targets(&mut self, targets: Vec<DiffTarget>, ctx: &mut ViewContext<Self>) {
if self.targets == targets {
return;
}
self.targets = targets;
self.refresh_filtered();
self.scroll_list_to_top();
@@ -192,6 +200,12 @@ impl CodeReviewDiffMenu {
})
.collect();
}
self.filtered_snapshot = Arc::new(
self.filtered
.iter()
.filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone())))
.collect(),
);
self.selected_index = if self.filtered.is_empty() {
None
} else {
@@ -302,11 +316,7 @@ impl CodeReviewDiffMenu {
let appearance = Appearance::as_ref(ctx);
let theme = appearance.theme();
let selected = self.selected_index;
let filtered_snapshot: Vec<(DiffTarget, Option<FuzzyMatchResult>)> = self
.filtered
.iter()
.filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone())))
.collect();
let filtered_snapshot = self.filtered_snapshot.clone();
let filtered_len = filtered_snapshot.len();
let list = UniformList::new(
+1 -1
View File
@@ -24,7 +24,7 @@ use crate::code_review::diff_state::DiffMode;
use crate::ui_components::icons::Icon;
/// A single selectable target in the diff selector menu.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffTarget {
pub label: String,
pub mode: DiffMode,
+122 -59
View File
@@ -19,6 +19,7 @@ cfg_if::cfg_if! {
use std::fs;
}
}
use futures::stream::{self, StreamExt, TryStreamExt};
#[cfg(not(target_family = "wasm"))]
use galaxy_core::channel::ChannelState;
use galaxy_core::send_telemetry_from_ctx;
@@ -31,6 +32,7 @@ use galaxyui::AppContext;
use galaxyui::{ModelContext, SingletonEntity};
use crate::code_review::diff_size_limits::{compute_diff_size, DiffSize};
use crate::code_review::merge_conflicts::parse_conflicts;
use crate::code_review::{git_actions, is_file_autogenerated, CodeReviewTelemetryEvent};
use crate::features::FeatureFlag;
use crate::server::server_api::ServerApiProvider;
@@ -1783,34 +1785,14 @@ impl LocalDiffStateModel {
// Get binary file information using git diff --numstat
let binary_files = Self::get_binary_files(repo_path).await?;
// Then get the diff for each file
let mut files = Vec::new();
let mut total_additions = 0;
let mut total_deletions = 0;
for (file_path, status) in changed_files {
let is_binary = binary_files.contains(&file_path);
let mut file_diff =
Self::get_file_diff(repo_path, &file_path, &status, is_binary, None).await?;
// Never read or ship base content for binary files: it can't be
// inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x.
let content_at_head = if is_binary {
None
} else {
Self::get_file_content_at_head(repo_path, &file_path, &status).await
};
file_diff.is_autogenerated =
is_file_autogenerated(&file_path, content_at_head.as_deref());
total_additions += file_diff.additions();
total_deletions += file_diff.deletions();
files.push(FileDiffAndContent {
file_diff,
content_at_head,
});
}
// Git operations are independent per file. Keep concurrency bounded so
// a large changeset gets parallel I/O without spawning an unbounded
// number of subprocesses, and collect in status order for stable UI.
let files =
Self::load_file_diffs_concurrently(repo_path, changed_files, binary_files, None)
.await?;
let total_additions = files.iter().map(|file| file.file_diff.additions()).sum();
let total_deletions = files.iter().map(|file| file.file_diff.deletions()).sum();
Ok(GitDiffWithBaseContent {
files_changed: files.len(),
@@ -1820,6 +1802,62 @@ impl LocalDiffStateModel {
})
}
async fn load_file_diffs_concurrently(
repo_path: &Path,
changed_files: Vec<(String, GitFileStatus)>,
binary_files: std::collections::HashSet<String>,
merge_base: Option<&str>,
) -> Result<Vec<FileDiffAndContent>> {
const MAX_CONCURRENT_FILE_DIFFS: usize = 8;
let repo_path = repo_path.to_path_buf();
let merge_base = merge_base.map(str::to_owned);
let files: Vec<Option<FileDiffAndContent>> =
stream::iter(changed_files.into_iter().map(|(file_path, status)| {
let repo_path = repo_path.clone();
let merge_base = merge_base.clone();
let is_binary = binary_files.contains(&file_path);
async move {
let file = match merge_base.as_deref() {
Some(base) => {
Self::file_diff_for_path(
is_binary,
&repo_path,
&file_path,
&status,
Some(base),
)
.await?
}
None => {
let mut file_diff = Self::get_file_diff(
&repo_path, &file_path, &status, is_binary, None,
)
.await?;
let content_at_head = if is_binary && !status.is_conflicted() {
None
} else {
Self::get_file_content_at_head(&repo_path, &file_path, &status)
.await
};
file_diff.is_autogenerated =
is_file_autogenerated(&file_path, content_at_head.as_deref());
Some(FileDiffAndContent {
file_diff,
content_at_head,
})
}
};
Ok::<_, anyhow::Error>(file)
}
}))
.buffered(MAX_CONCURRENT_FILE_DIFFS)
.try_collect()
.await?;
Ok(files.into_iter().flatten().collect())
}
async fn diff_state_against_base_branch(
repo_path: &Path,
should_fetch_base: bool,
@@ -1969,13 +2007,14 @@ impl LocalDiffStateModel {
&& (file_diff.hunks.is_empty() || file_diff.is_empty())
&& !status.is_renamed()
&& !status.is_new_file()
&& !status.is_conflicted()
{
return Ok(None);
}
// Never read or ship base content for binary files: it can't be
// inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x.
let content_at_head = if is_binary {
let content_at_head = if file_diff.is_binary {
None
} else {
match &merge_base {
@@ -2037,9 +2076,20 @@ impl LocalDiffStateModel {
let status_files = Self::parse_git_status(&status_output)?;
// Add untracked files to the changed files list
// Add untracked files and preserve unmerged status in the branch diff.
// `git diff --name-status` can report a conflicted path as modified,
// while `git status` is authoritative for whether it needs resolving.
for (file_path, status) in status_files {
if matches!(status, GitFileStatus::Untracked) {
if status.is_conflicted() {
if let Some((_, existing_status)) = changed_files
.iter_mut()
.find(|(path, _)| path == &file_path)
{
*existing_status = status;
} else {
changed_files.push((file_path, status));
}
} else if matches!(status, GitFileStatus::Untracked) {
changed_files.push((file_path, status));
}
}
@@ -2087,29 +2137,15 @@ impl LocalDiffStateModel {
// Get binary file information using git diff --numstat against merge base
let binary_files = Self::get_binary_files_vs_commit(repo_path, &merge_base).await?;
// Get the diff for each file
let mut files = Vec::new();
let mut total_additions = 0;
let mut total_deletions = 0;
for (file_path, status) in changed_files {
let is_binary = binary_files.contains(&file_path);
let file_diff = Self::file_diff_for_path(
is_binary,
repo_path,
&file_path,
&status,
Some(&merge_base),
)
.await?;
if let Some(file_diff) = file_diff {
total_additions += file_diff.file_diff.additions();
total_deletions += file_diff.file_diff.deletions();
files.push(file_diff);
}
}
let files = Self::load_file_diffs_concurrently(
repo_path,
changed_files,
binary_files,
Some(&merge_base),
)
.await?;
let total_additions = files.iter().map(|file| file.file_diff.additions()).sum();
let total_deletions = files.iter().map(|file| file.file_diff.deletions()).sum();
Ok(GitDiffWithBaseContent {
files_changed: files.len(),
@@ -2376,9 +2412,16 @@ impl LocalDiffStateModel {
) -> Result<FileDiff> {
let mut hunks = Vec::new();
let mut max_line_number = 0;
let conflict_count = if status.is_conflicted() {
Self::conflict_count(repo_path, file_path).await
} else {
0
};
// If it's a binary file, don't fetch or parse the diff content
if is_binary {
// If it's a binary file, don't fetch or parse the diff content. A
// conflicted text file may be reported as binary by `git diff`, so
// only bypass this path when no conflict markers are present.
if is_binary && conflict_count == 0 {
return Ok(FileDiff {
file_path: file_path.to_owned(),
status: status.clone(),
@@ -2387,6 +2430,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2404,6 +2448,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2511,15 +2556,19 @@ impl LocalDiffStateModel {
"Failed to get file diff for {file_path}{}: {error}",
commit.map(|c| format!(" vs {c}")).unwrap_or_default()
);
// If diff fails, treat as binary or empty
// Unmerged files may not have a normal `git diff HEAD` patch,
// but their working-tree contents are still safe to render and
// resolve in the editor.
let is_conflicted = status.is_conflicted();
return Ok(FileDiff {
file_path: file_path.to_owned(),
status: status.clone(),
hunks: Arc::new(hunks),
is_binary: true,
is_binary: !is_conflicted,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count,
size: DiffSize::Normal,
});
}
@@ -2530,6 +2579,7 @@ impl LocalDiffStateModel {
if diff_output
.lines()
.any(|line| line.starts_with("Binary files ") && line.contains(" differ"))
&& conflict_count == 0
{
return Ok(FileDiff {
file_path: file_path.to_owned(),
@@ -2539,6 +2589,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2566,14 +2617,26 @@ impl LocalDiffStateModel {
file_path: file_path.to_owned(),
status: status.clone(),
hunks: Arc::new(hunks),
is_binary,
is_binary: is_binary && conflict_count == 0,
is_autogenerated: false,
max_line_number,
has_hidden_bidi_chars,
conflict_count,
size,
})
}
async fn conflict_count(repo_path: &Path, file_path: &str) -> usize {
let path = repo_path.join(file_path);
if !path.is_file() {
return 0;
}
async_fs::read_to_string(path)
.await
.map(|content| parse_conflicts(&content).len())
.unwrap_or(0)
}
/// Parses diff hunks from git diff output
pub(crate) fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>> {
let mut hunks = Vec::new();
+8
View File
@@ -109,6 +109,10 @@ impl GitFileStatus {
pub fn is_new_file(&self) -> bool {
matches!(self, Self::New | Self::Untracked)
}
pub fn is_conflicted(&self) -> bool {
matches!(self, Self::Conflicted)
}
}
#[derive(Clone, Debug)]
@@ -177,6 +181,10 @@ pub struct FileDiff {
pub is_autogenerated: bool,
pub max_line_number: usize,
pub has_hidden_bidi_chars: bool,
/// Number of complete conflict-marker blocks in the working-tree file.
/// This is metadata only; the live editor remains the source of truth for
/// applying resolutions.
pub conflict_count: usize,
pub size: DiffSize,
}
@@ -99,6 +99,7 @@ fn simple_file_with_content(path: &str, content_at_base: Option<&str>) -> FileDi
is_autogenerated: false,
max_line_number: 10,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
},
content_at_head: content_at_base.map(str::to_string),
@@ -691,6 +692,7 @@ fn apply_file_delta_none_removes_file() {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
}],
total_additions: 0,

Some files were not shown because too many files have changed in this diff Show More