Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-05 15:10:16 +01:00 committed by GitHub
commit 214d929281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 309 additions and 345 deletions

View file

@ -1177,6 +1177,7 @@ pub enum LoadError {
FailedToInstall(SharedString),
Exited {
status: ExitStatus,
stderr: Option<SharedString>,
},
Other(SharedString),
}
@ -1195,7 +1196,7 @@ impl Display for LoadError {
)
}
LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"),
LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
LoadError::Exited { status, .. } => write!(f, "Server exited with status {status}"),
LoadError::Other(msg) => write!(f, "{msg}"),
}
}

View file

@ -6061,7 +6061,6 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit sensitive file".to_string(),
path: "root/sensitive_config.txt".into(),
mode: crate::EditFileMode::Edit,
content: None,
@ -6496,7 +6495,6 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
let _task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit README".to_string(),
path: "root/README.md".into(),
mode: crate::EditFileMode::Edit,
content: None,
@ -6569,7 +6567,6 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
let _task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit local settings".to_string(),
path: "root/.zed/settings.json".into(),
mode: crate::EditFileMode::Edit,
content: None,

File diff suppressed because it is too large Load diff

View file

@ -12,10 +12,8 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput};
#[serde(rename_all = "snake_case")]
#[schemars(inline)]
pub enum Timezone {
/// Use UTC for the datetime.
#[serde(alias = "UTC", alias = "Utc")]
Utc,
/// Use local time for the datetime.
#[serde(alias = "LOCAL", alias = "Local")]
Local,
}
@ -24,7 +22,7 @@ pub enum Timezone {
/// Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct NowToolInput {
/// The timezone to use for the datetime.
/// The timezone to use for the datetime. Use `utc` for UTC, or `local` for the system's local time.
timezone: Timezone,
}

View file

@ -381,7 +381,6 @@ pub fn collect_symlink_escapes<'a>(
pub fn authorize_file_edit(
tool_name: &str,
path: &Path,
display_description: &str,
thread: &WeakEntity<Thread>,
event_stream: &ToolCallEventStream,
cx: &mut App,
@ -396,7 +395,7 @@ pub fn authorize_file_edit(
}
let path_owned = path.to_path_buf();
let display_description = display_description.to_string();
let title = format!("Edit {}", util::markdown::MarkdownInlineCode(&path_str));
let tool_name = tool_name.to_string();
let thread = thread.clone();
let event_stream = event_stream.clone();
@ -486,7 +485,7 @@ pub fn authorize_file_edit(
vec![path_owned.to_string_lossy().to_string()],
);
event_stream.authorize_always_prompt(
format!("{} (local settings)", display_description),
format!("{title} (local settings)"),
context,
cx,
)
@ -499,11 +498,7 @@ pub fn authorize_file_edit(
&tool_name,
vec![path_owned.to_string_lossy().to_string()],
);
event_stream.authorize_always_prompt(
format!("{} (settings)", display_description),
context,
cx,
)
event_stream.authorize_always_prompt(format!("{title} (settings)"), context, cx)
});
return authorize.await;
}
@ -518,7 +513,7 @@ pub fn authorize_file_edit(
&tool_name,
vec![path_owned.to_string_lossy().to_string()],
);
event_stream.authorize(&display_description, context, cx)
event_stream.authorize(&title, context, cx)
});
authorize.await
}

View file

@ -20,7 +20,7 @@ use project::{AgentId, Project};
use remote::remote_client::Interactive;
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::process::{ExitStatus, Stdio};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::{any::Any, cell::RefCell, collections::VecDeque};
@ -195,6 +195,34 @@ impl AcpDebugLog {
sender.try_send(message.clone()).log_err();
}
}
fn trailing_stderr(&self) -> Option<String> {
let state = self.state.lock().ok()?;
let mut lines = state
.messages
.iter()
.rev()
.take_while(|message| matches!(&message.message, AcpDebugMessageContent::Stderr { .. }))
.filter_map(|message| match &message.message {
AcpDebugMessageContent::Stderr { line } if !line.is_empty() => Some(line.as_ref()),
_ => None,
})
.collect::<Vec<_>>();
if lines.is_empty() {
return None;
}
lines.reverse();
Some(lines.join("\n"))
}
}
fn exited_load_error_with_stderr(status: ExitStatus, debug_log: &AcpDebugLog) -> LoadError {
LoadError::Exited {
status,
stderr: debug_log.trailing_stderr().map(SharedString::from),
}
}
/// Awaits the response to an ACP request from a GPUI foreground task.
@ -714,6 +742,7 @@ impl AcpConnection {
log::trace!("Spawned (pid: {})", child.id());
let sessions = Rc::new(RefCell::new(HashMap::default()));
let debug_log = AcpDebugLog::default();
let (release_channel, version): (Option<&str>, String) = cx.update(|cx| {
(
@ -729,7 +758,6 @@ impl AcpConnection {
// Set up the foreground dispatch channel for bridging Send handler
// closures to the !Send foreground thread.
let (dispatch_tx, dispatch_rx) = mpsc::unbounded::<ForegroundWork>();
let debug_log = AcpDebugLog::default();
let incoming_lines = futures::io::BufReader::new(stdout).lines();
let tapped_incoming = incoming_lines.inspect({
@ -756,37 +784,6 @@ impl AcpConnection {
let transport = Lines::new(tapped_outgoing, tapped_incoming);
// `connect_client_future` installs the production handler set and
// hands us back both the connection-future (to run on a background
// executor) and a oneshot receiver that produces the
// `ConnectionTo<Agent>` once the transport handshake is ready.
let (connection_tx, connection_rx) = futures::channel::oneshot::channel();
let connection_future =
connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx);
let io_task = cx.background_spawn(async move {
if let Err(err) = connection_future.await {
log::error!("ACP connection error: {err}");
}
});
let connection: ConnectionTo<Agent> = connection_rx
.await
.context("Failed to receive ACP connection handle")?;
// Set up the foreground dispatch loop to process work items from handlers.
let dispatch_context = ClientContext {
sessions: sessions.clone(),
session_list: client_session_list.clone(),
};
let dispatch_task = cx.spawn({
let mut dispatch_rx = dispatch_rx;
async move |cx| {
while let Some(work) = dispatch_rx.next().await {
work.run(cx, &dispatch_context);
}
}
});
let stderr_task = cx.background_spawn({
let debug_log = debug_log.clone();
async move {
@ -804,17 +801,53 @@ impl AcpConnection {
}
});
let wait_task = cx.spawn({
let sessions = sessions.clone();
let status_fut = child.status();
async move |cx| {
let status = status_fut.await?;
emit_load_error_to_all_sessions(&sessions, LoadError::Exited { status }, cx);
anyhow::Ok(())
// `connect_client_future` installs the production handler set and
// hands us back both the connection-future (to run on a background
// executor) and a oneshot receiver that produces the
// `ConnectionTo<Agent>` once the transport handshake is ready.
let (connection_tx, connection_rx) = futures::channel::oneshot::channel();
let connection_future =
connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx);
let io_task = cx.background_spawn(async move {
if let Err(err) = connection_future.await {
log::error!("ACP connection error: {err}");
}
});
let response = into_foreground_future(
let connection_rx = async move {
connection_rx
.await
.context("Failed to receive ACP connection handle")
}
.boxed_local();
let status_fut = child.status().boxed_local();
let (connection, status_fut) = match futures::future::select(connection_rx, status_fut)
.await
{
futures::future::Either::Left((connection, status_fut)) => (connection?, status_fut),
futures::future::Either::Right((status, _connection_rx)) => match status {
Ok(status) => return Err(exited_load_error_with_stderr(status, &debug_log).into()),
Err(err) => {
return Err(anyhow!("agent server exited before initialization: {err}"));
}
},
};
// Set up the foreground dispatch loop to process work items from handlers.
let dispatch_context = ClientContext {
sessions: sessions.clone(),
session_list: client_session_list.clone(),
};
let dispatch_task = cx.spawn({
let mut dispatch_rx = dispatch_rx;
async move |cx| {
while let Some(work) = dispatch_rx.next().await {
work.run(cx, &dispatch_context);
}
}
});
let initialize_response = into_foreground_future(
connection.send_request(
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
.client_capabilities(
@ -835,12 +868,38 @@ impl AcpConnection {
),
),
)
.await?;
.map(|response| response.map_err(anyhow::Error::from))
.boxed_local();
let (response, status_fut) = match futures::future::select(initialize_response, status_fut)
.await
{
futures::future::Either::Left((response, status_fut)) => (response?, status_fut),
futures::future::Either::Right((status, _initialize_response)) => match status {
Ok(status) => return Err(exited_load_error_with_stderr(status, &debug_log).into()),
Err(err) => {
return Err(anyhow!("agent server exited before initialization: {err}"));
}
},
};
if response.protocol_version < MINIMUM_SUPPORTED_VERSION {
return Err(UnsupportedVersion.into());
}
let wait_task = cx.spawn({
let sessions = sessions.clone();
let debug_log = debug_log.clone();
async move |cx| {
let status = status_fut.await?;
emit_load_error_to_all_sessions(
&sessions,
exited_load_error_with_stderr(status, &debug_log),
cx,
);
anyhow::Ok(())
}
});
let telemetry_id = response
.agent_info
// Use the one the agent provides if we have one
@ -1881,7 +1940,10 @@ pub mod test_support {
while let Ok(status) = exit_rx.recv().await {
emit_load_error_to_all_sessions(
&connection.sessions,
LoadError::Exited { status },
LoadError::Exited {
status,
stderr: None,
},
cx,
);
}
@ -2373,6 +2435,85 @@ mod tests {
assert_eq!(task.label, "Login");
}
#[test]
fn trailing_stderr_only_uses_final_stderr_block() {
let debug_log = AcpDebugLog::default();
debug_log.record_line(AcpDebugMessageDirection::Stderr, "stale stderr");
debug_log.record_line(
AcpDebugMessageDirection::Incoming,
r#"{"method":"initialized"}"#,
);
assert_eq!(debug_log.trailing_stderr(), None);
debug_log.record_line(AcpDebugMessageDirection::Stderr, "recent stderr");
assert_eq!(
debug_log.trailing_stderr().as_deref(),
Some("recent stderr")
);
}
#[cfg(not(windows))]
#[gpui::test]
async fn startup_returns_error_when_agent_exits_before_initialization(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let store = settings::SettingsStore::test(cx);
cx.set_global(store);
});
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let project = project::Project::example([temp_dir.path()], &mut cx.to_async()).await;
let agent_server_store =
project.read_with(cx, |project, _| project.agent_server_store().downgrade());
let command = AgentServerCommand {
path: "/bin/sh".into(),
args: vec![
"-c".into(),
r#"printf '%s\n' 'npm error code ETARGET' 'npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.32.0 with a date before 4/28/2026, 12:11:38 PM.' >&2; exit 1"#.into(),
],
env: None,
};
let mut async_cx = cx.to_async();
let startup = AcpConnection::stdio(
AgentId::new("test-agent"),
project,
command,
agent_server_store,
None,
None,
HashMap::default(),
&mut async_cx,
)
.fuse();
let timeout = cx
.background_executor
.timer(std::time::Duration::from_secs(5))
.fuse();
futures::pin_mut!(startup, timeout);
let result = futures::select! {
result = startup => result,
_ = timeout => panic!("timed out waiting for failed ACP startup"),
};
let Err(error) = result else {
panic!("expected ACP startup to fail");
};
let load_error = error
.downcast::<LoadError>()
.expect("startup failure should preserve the typed load error");
match load_error {
LoadError::Exited { status, .. } => {
assert!(!status.success(), "expected non-zero exit status");
}
error => panic!("expected exited load error, got: {error:?}"),
};
}
async fn connect_fake_agent(
cx: &mut gpui::TestAppContext,
) -> (

View file

@ -56,7 +56,6 @@ file_icons.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
git_ui.workspace = true
fuzzy.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@ -124,6 +123,7 @@ clock = { workspace = true, features = ["test-support"] }
db = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
eval_utils.workspace = true
git_ui.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
indoc.workspace = true

View file

@ -2159,11 +2159,17 @@ impl ConversationView {
msg.into(),
Some(self.create_copy_button(msg.to_string()).into_any_element()),
),
LoadError::Exited { status } => (
"Failed to Launch",
format!("Server exited with status {status}").into(),
None,
),
LoadError::Exited { status, stderr } => {
let mut message = format!("Server exited with status {status}");
if let Some(stderr) = stderr {
message.push_str("\n");
message.push_str(stderr);
};
let action_slot = stderr
.is_some()
.then(|| self.create_copy_button(message.clone()).into_any_element());
("Failed to Launch", message.into(), action_slot)
}
LoadError::Other(msg) => (
"Failed to Launch",
msg.into(),

View file

@ -449,7 +449,7 @@ fn create_editor_diff(
editor.set_show_vertical_scrollbar(false, cx);
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
editor.set_soft_wrap_mode(SoftWrap::None, cx);
editor.scroll_manager.set_forbid_vertical_scroll(true);
editor.set_forbid_vertical_scroll(true);
editor.set_show_indent_guides(false, cx);
editor.set_read_only(true);
editor.set_delegate_open_excerpts(true);

View file

@ -1425,7 +1425,7 @@ impl InlineAssistant {
editor.set_show_gutter(false, cx);
editor.set_offset_content(false, cx);
editor.disable_mouse_wheel_zoom();
editor.scroll_manager.set_forbid_vertical_scroll(true);
editor.set_forbid_vertical_scroll(true);
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
editor.highlight_rows::<DeletedLines>(

View file

@ -1157,7 +1157,12 @@ pub struct Editor {
pub display_map: Entity<DisplayMap>,
placeholder_display_map: Option<Entity<DisplayMap>>,
pub selections: SelectionsCollection,
pub scroll_manager: ScrollManager,
/// Manages the scroll position for the given editor.
///
/// Whenever you want to modify the scroll position of the editor, you should
/// usually use the existing available APIs as opposed to directly interacting
/// with the scroll manager.
pub(crate) scroll_manager: ScrollManager,
/// When inline assist editors are linked, they all render cursors because
/// typing enters text into each of them, even the ones that aren't focused.
pub(crate) show_cursor_when_unfocused: bool,

View file

@ -623,6 +623,14 @@ impl Editor {
self.scroll_manager.has_autoscroll_request()
}
pub fn set_forbid_vertical_scroll(&mut self, forbid: bool) {
self.scroll_manager.set_forbid_vertical_scroll(forbid);
}
pub fn scroll_top_display_point(&self, snapshot: &DisplaySnapshot, cx: &App) -> DisplayPoint {
self.scroll_manager.scroll_top_display_point(snapshot, cx)
}
pub fn vertical_scroll_margin(&self) -> usize {
self.scroll_manager.vertical_scroll_margin as usize
}

View file

@ -1,3 +1,4 @@
**/jobs
**/*.egg-info
**/__pycache__
uv.lock

View file

@ -7,12 +7,12 @@
# Or use the helper script:
# crates/eval_cli/script/build-linux
FROM rust:1.94.1 AS builder
FROM rust:1.95.0 AS builder
WORKDIR /app
# Pre-install the toolchain specified in rust-toolchain.toml so it is cached.
RUN rustup toolchain install 1.94.1 --profile minimal \
RUN rustup toolchain install 1.95.0 --profile minimal \
--component rustfmt --component clippy --component rust-analyzer --component rust-src \
--target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl --target x86_64-unknown-linux-gnu

View file

@ -70,7 +70,7 @@ struct Args {
workdir: PathBuf,
/// Instruction/prompt text. If omitted, read from --instruction-file or stdin.
#[arg(long)]
#[arg(long, allow_hyphen_values = true)]
instruction: Option<String>,
/// Language model to use, in `provider/model` format.

View file

@ -52,19 +52,20 @@ class ZedAgent(BaseInstalledAgent):
return "zed"
async def _detect_workdir(self, environment: BaseEnvironment) -> str:
"""Detect the repo working directory inside the container.
"""Detect the working directory inside the container.
Checks, in order:
1. Explicit ``EVAL_CLI_WORKDIR`` extra-env override
2. ``/app`` (SWE-bench Pro)
3. ``/testbed`` (SWE-bench Verified)
4. ``/repo``
5. First git repo found under ``/`` (max depth 3)
2. Well-known dirs with a ``.git`` subdirectory (SWE-bench style)
3. First git repo found under ``/`` (max depth 3)
4. Well-known dirs that exist at all (terminal-bench style)
5. The container's default working directory (``pwd``)
"""
override = self._extra_env.get("EVAL_CLI_WORKDIR")
if override:
return override
# First: try to find a git repo (SWE-bench, etc.)
result = await self.exec_as_agent(
environment,
command=(
@ -75,13 +76,29 @@ class ZedAgent(BaseInstalledAgent):
'| head -1 | sed "s|/.git$||"'
),
)
workdir = result.stdout.strip()
if not workdir:
raise RuntimeError(
"Could not find a git repository in the container. "
"Set EVAL_CLI_WORKDIR explicitly via --ae EVAL_CLI_WORKDIR=/path/to/repo"
)
return workdir
workdir = (result.stdout or "").strip()
if workdir:
return workdir
# Fallback: use the first well-known directory that exists,
# even without .git (terminal-bench containers aren't git repos).
result = await self.exec_as_agent(
environment,
command=(
"for d in /app /testbed /repo /root /home; do "
' if [ -d "$d" ]; then echo "$d"; exit 0; fi; '
"done; "
"pwd"
),
)
workdir = (result.stdout or "").strip()
if workdir:
return workdir
raise RuntimeError(
"Could not detect a working directory in the container. "
"Set EVAL_CLI_WORKDIR explicitly via --ae EVAL_CLI_WORKDIR=/path/to/repo"
)
async def install(self, environment: BaseEnvironment) -> None:
# Detect the package manager and install base dependencies.
@ -426,12 +443,18 @@ class ZedAgent(BaseInstalledAgent):
env=env,
)
# Only generate a patch if the workdir is a git repo
# (SWE-bench style). Terminal-bench containers aren't git repos.
await self.exec_as_agent(
environment,
command=(
'if [ -d ".git" ]; then '
"git add -A && "
"git diff --cached HEAD > /logs/agent/patch.diff && "
'echo "Patch size: $(wc -c < /logs/agent/patch.diff) bytes"'
'echo "Patch size: $(wc -c < /logs/agent/patch.diff) bytes"; '
"else "
'echo "No git repo found, skipping patch generation"; '
"fi"
),
cwd=workdir,
)

View file

@ -3,7 +3,7 @@ name = "zed-eval"
version = "0.1.0"
description = "Harbor agent wrapper for Zed's eval-cli"
requires-python = ">=3.12"
dependencies = ["harbor"]
dependencies = ["harbor==0.6.4"]
[build-system]
requires = ["setuptools"]

View file

@ -109,9 +109,7 @@ impl Vim {
self.update_editor(cx, |vim, editor, cx| {
let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
let old_top = editor
.scroll_manager
.scroll_top_display_point(&display_snapshot, cx);
let old_top = editor.scroll_top_display_point(&display_snapshot, cx);
if editor.scroll_hover(amount, window, cx) {
return;
@ -143,9 +141,7 @@ impl Vim {
};
let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
let top = editor
.scroll_manager
.scroll_top_display_point(&display_snapshot, cx);
let top = editor.scroll_top_display_point(&display_snapshot, cx);
let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin;
let mut move_cursor = |map: &editor::display_map::DisplaySnapshot,