From 63cc3291e3d23e39a675dc6376921ab72b3e1c95 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 01:11:13 -0300 Subject: [PATCH 01/11] Fix CC tool state on cancel --- crates/agent_servers/src/claude.rs | 57 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 09d08fdcf8..588d6e9f45 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -6,7 +6,7 @@ use context_server::listener::McpServerTool; use project::Project; use settings::SettingsStore; use smol::process::Child; -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::fmt::Display; use std::path::Path; use std::rc::Rc; @@ -153,13 +153,13 @@ impl AgentConnection for ClaudeAgentConnection { }) .detach(); - let pending_cancellation = Rc::new(Cell::new(PendingCancellation::None)); + let cancellation_state = Rc::new(RefCell::new(CancellationState::None)); let end_turn_tx = Rc::new(RefCell::new(None)); let handler_task = cx.spawn({ let end_turn_tx = end_turn_tx.clone(); let mut thread_rx = thread_rx.clone(); - let cancellation_state = pending_cancellation.clone(); + let cancellation_state = cancellation_state.clone(); async move |cx| { while let Some(message) = incoming_message_rx.next().await { ClaudeAgentSession::handle_message( @@ -193,7 +193,7 @@ impl AgentConnection for ClaudeAgentConnection { let session = ClaudeAgentSession { outgoing_tx, end_turn_tx, - pending_cancellation, + cancellation_state, _handler_task: handler_task, _mcp_server: Some(permission_mcp_server), }; @@ -260,10 +260,10 @@ impl AgentConnection for ClaudeAgentConnection { return Task::ready(Err(anyhow!(err))); } - let cancellation_state = session.pending_cancellation.clone(); + let cancellation_state = session.cancellation_state.clone(); cx.foreground_executor().spawn(async move { let result = rx.await??; - cancellation_state.set(PendingCancellation::None); + *cancellation_state.borrow_mut() = CancellationState::None; Ok(result) }) } @@ -277,9 +277,9 @@ impl AgentConnection for ClaudeAgentConnection { let request_id = new_request_id(); - session.pending_cancellation.set(PendingCancellation::Sent { + *session.cancellation_state.borrow_mut() = CancellationState::Requested { request_id: request_id.clone(), - }); + }; session .outgoing_tx @@ -350,27 +350,33 @@ fn spawn_claude( struct ClaudeAgentSession { outgoing_tx: UnboundedSender, end_turn_tx: Rc>>>>, - pending_cancellation: Rc>, + cancellation_state: Rc>, _mcp_server: Option, _handler_task: Task<()>, } -#[derive(Debug, Default, PartialEq)] -enum PendingCancellation { +#[derive(Debug, Default)] +enum CancellationState { #[default] None, - Sent { + Requested { request_id: String, }, Confirmed, } +impl CancellationState { + fn is_confirmed(&self) -> bool { + matches!(self, CancellationState::Confirmed) + } +} + impl ClaudeAgentSession { async fn handle_message( mut thread_rx: watch::Receiver>, message: SdkMessage, end_turn_tx: Rc>>>>, - pending_cancellation: Rc>, + cancellation_state: Rc>, cx: &mut AsyncApp, ) { match message { @@ -393,15 +399,13 @@ impl ClaudeAgentSession { for chunk in message.content.chunks() { match chunk { ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - let state = pending_cancellation.take(); - if state != PendingCancellation::Confirmed { + if !cancellation_state.borrow().is_confirmed() { thread .update(cx, |thread, cx| { thread.push_user_content_block(text.into(), cx) }) .log_err(); } - pending_cancellation.set(state); } ContentChunk::ToolResult { content, @@ -414,7 +418,15 @@ impl ClaudeAgentSession { acp::ToolCallUpdate { id: acp::ToolCallId(tool_use_id.into()), fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), + status: if cancellation_state + .borrow() + .is_confirmed() + { + // Do not set to completed if turn was cancelled + None + } else { + Some(acp::ToolCallStatus::Completed) + }, content: (!content.is_empty()) .then(|| vec![content.into()]), ..Default::default() @@ -544,7 +556,7 @@ impl ClaudeAgentSession { if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() { if is_error || (subtype == ResultErrorType::ErrorDuringExecution - && pending_cancellation.take() != PendingCancellation::Confirmed) + && !cancellation_state.borrow().is_confirmed()) { end_turn_tx .send(Err(anyhow!( @@ -566,14 +578,11 @@ impl ClaudeAgentSession { } SdkMessage::ControlResponse { response } => { if matches!(response.subtype, ResultErrorType::Success) { - let pending_cancellation_value = pending_cancellation.take(); - - if let PendingCancellation::Sent { request_id } = &pending_cancellation_value + let mut cancellation_state = cancellation_state.borrow_mut(); + if let CancellationState::Requested { request_id } = &*cancellation_state && request_id == &response.request_id { - pending_cancellation.set(PendingCancellation::Confirmed); - } else { - pending_cancellation.set(pending_cancellation_value); + *cancellation_state = CancellationState::Confirmed; } } } From 4b94e90899787f7c9242b4e71f99666ae1002d57 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 01:18:38 -0300 Subject: [PATCH 02/11] Use replace --- .github/workflows/agent_servers_e2e.yml | 118 ++++++++++++++++++++++++ crates/agent_servers/src/claude.rs | 10 +- crates/agent_servers/src/e2e_tests.rs | 7 +- 3 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/agent_servers_e2e.yml diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml new file mode 100644 index 0000000000..9f58266f9d --- /dev/null +++ b/.github/workflows/agent_servers_e2e.yml @@ -0,0 +1,118 @@ +name: Agent Servers E2E Tests + +on: + schedule: + # Run once a day at 2:00 AM UTC + - cron: "0 2 * * *" + + push: + branches: + - main + - "v[0-9]+.[0-9]+.x" + paths: + - "crates/agent_servers/**" + - "crates/acp_thread/**" + - ".github/workflows/agent_servers_e2e.yml" + + pull_request: + branches: + - "**" + paths: + - "crates/agent_servers/**" + - "crates/acp_thread/**" + - ".github/workflows/agent_servers_e2e.yml" + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + +jobs: + e2e-tests: + name: Run Agent Servers E2E Tests + if: github.repository_owner == 'zed-industries' + timeout-minutes: 60 + runs-on: + - buildjet-16vcpu-ubuntu-2204 + + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + - name: Checkout gemini-cli repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + repository: zed-industries/gemini-cli + ref: migrate-acp + path: gemini-cli + clean: false + + - name: Install Rust + shell: bash -euxo pipefail {0} + run: | + cargo install cargo-nextest --locked + + - name: Install Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "18" + + - name: Install Claude Code CLI + shell: bash -euxo pipefail {0} + run: | + npm install -g @anthropic-ai/claude-code + # Verify installation + which claude || echo "Claude CLI not found in PATH" + # Skip authentication if API key is not set (tests may use mock) + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "Anthropic API key is configured" + fi + + - name: Install and setup Gemini CLI + shell: bash -euxo pipefail {0} + run: | + # Install globally for potential fallback + npm install -g @google/gemini-cli + + # Also install dependencies for local gemini-cli repo + cd gemini-cli/packages/cli + npm install + cd - + + # Verify installations + which gemini || echo "Gemini CLI not found in PATH" + # Skip authentication if API key is not set (tests may use mock) + if [ -n "$GEMINI_API_KEY" ]; then + echo "Gemini API key is configured" + fi + + - name: Limit target directory size + shell: bash -euxo pipefail {0} + run: script/clear-target-dir-if-larger-than 100 + + - name: Run E2E tests + shell: bash -euxo pipefail {0} + run: | + cargo nextest run \ + --package agent_servers \ + --features e2e \ + --no-fail-fast + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + target/nextest/default/*.xml + retention-days: 7 diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 588d6e9f45..98de041047 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -263,7 +263,7 @@ impl AgentConnection for ClaudeAgentConnection { let cancellation_state = session.cancellation_state.clone(); cx.foreground_executor().spawn(async move { let result = rx.await??; - *cancellation_state.borrow_mut() = CancellationState::None; + cancellation_state.replace(CancellationState::None); Ok(result) }) } @@ -277,9 +277,11 @@ impl AgentConnection for ClaudeAgentConnection { let request_id = new_request_id(); - *session.cancellation_state.borrow_mut() = CancellationState::Requested { - request_id: request_id.clone(), - }; + session + .cancellation_state + .replace(CancellationState::Requested { + request_id: request_id.clone(), + }); session .outgoing_tx diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 05f874bd30..ec6ca29b9d 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -246,7 +246,7 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; - let full_turn = thread.update(cx, |thread, cx| { + let _ = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, cx, @@ -285,9 +285,8 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon id.clone() }); - let _ = thread.update(cx, |thread, cx| thread.cancel(cx)); - full_turn.await.unwrap(); - thread.read_with(cx, |thread, _| { + thread.update(cx, |thread, cx| thread.cancel(cx)).await; + thread.read_with(cx, |thread, _cx| { let AgentThreadEntry::ToolCall(ToolCall { status: ToolCallStatus::Canceled, .. From 7f9adae3a372702214fb4846caf828527c9b036f Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 11:36:29 -0300 Subject: [PATCH 03/11] Combine end_turn_tx and cancellation_state into one enum --- crates/agent_servers/src/claude.rs | 143 ++++++++++++++++------------- 1 file changed, 80 insertions(+), 63 deletions(-) diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 98de041047..c00c3877cb 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -153,20 +153,17 @@ impl AgentConnection for ClaudeAgentConnection { }) .detach(); - let cancellation_state = Rc::new(RefCell::new(CancellationState::None)); + let turn_state = Rc::new(RefCell::new(TurnState::None)); - let end_turn_tx = Rc::new(RefCell::new(None)); let handler_task = cx.spawn({ - let end_turn_tx = end_turn_tx.clone(); + let turn_state = turn_state.clone(); let mut thread_rx = thread_rx.clone(); - let cancellation_state = cancellation_state.clone(); async move |cx| { while let Some(message) = incoming_message_rx.next().await { ClaudeAgentSession::handle_message( thread_rx.clone(), message, - end_turn_tx.clone(), - cancellation_state.clone(), + turn_state.clone(), cx, ) .await @@ -192,8 +189,7 @@ impl AgentConnection for ClaudeAgentConnection { let session = ClaudeAgentSession { outgoing_tx, - end_turn_tx, - cancellation_state, + turn_state, _handler_task: handler_task, _mcp_server: Some(permission_mcp_server), }; @@ -225,8 +221,8 @@ impl AgentConnection for ClaudeAgentConnection { ))); }; - let (tx, rx) = oneshot::channel(); - session.end_turn_tx.borrow_mut().replace(tx); + let (end_tx, end_rx) = oneshot::channel(); + session.turn_state.replace(TurnState::InProgress { end_tx }); let mut content = String::new(); for chunk in params.prompt { @@ -260,12 +256,7 @@ impl AgentConnection for ClaudeAgentConnection { return Task::ready(Err(anyhow!(err))); } - let cancellation_state = session.cancellation_state.clone(); - cx.foreground_executor().spawn(async move { - let result = rx.await??; - cancellation_state.replace(CancellationState::None); - Ok(result) - }) + cx.foreground_executor().spawn(async move { end_rx.await? }) } fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { @@ -277,11 +268,17 @@ impl AgentConnection for ClaudeAgentConnection { let request_id = new_request_id(); - session - .cancellation_state - .replace(CancellationState::Requested { - request_id: request_id.clone(), - }); + let turn_state = session.turn_state.take(); + let TurnState::InProgress { end_tx } = session.turn_state.take() else { + // Already cancelled or idle, put it back + session.turn_state.replace(turn_state); + return; + }; + + session.turn_state.replace(TurnState::CancelRequested { + end_tx, + request_id: request_id.clone(), + }); session .outgoing_tx @@ -351,25 +348,48 @@ fn spawn_claude( struct ClaudeAgentSession { outgoing_tx: UnboundedSender, - end_turn_tx: Rc>>>>, - cancellation_state: Rc>, + turn_state: Rc>, _mcp_server: Option, _handler_task: Task<()>, } #[derive(Debug, Default)] -enum CancellationState { +enum TurnState { #[default] None, - Requested { + InProgress { + end_tx: oneshot::Sender>, + }, + CancelRequested { + end_tx: oneshot::Sender>, request_id: String, }, - Confirmed, + CancelConfirmed { + end_tx: oneshot::Sender>, + }, } -impl CancellationState { - fn is_confirmed(&self) -> bool { - matches!(self, CancellationState::Confirmed) +impl TurnState { + fn is_cancelled(&self) -> bool { + matches!(self, TurnState::CancelConfirmed { .. }) + } + + fn end_tx(self) -> Option>> { + match self { + TurnState::None => None, + TurnState::InProgress { end_tx, .. } => Some(end_tx), + TurnState::CancelRequested { end_tx, .. } => Some(end_tx), + TurnState::CancelConfirmed { end_tx } => Some(end_tx), + } + } + + fn confirm_cancellation(self, id: &str) -> Self { + match self { + TurnState::CancelRequested { request_id, end_tx } if request_id == id => { + TurnState::CancelConfirmed { end_tx } + } + _ => self, + } } } @@ -377,8 +397,7 @@ impl ClaudeAgentSession { async fn handle_message( mut thread_rx: watch::Receiver>, message: SdkMessage, - end_turn_tx: Rc>>>>, - cancellation_state: Rc>, + turn_state: Rc>, cx: &mut AsyncApp, ) { match message { @@ -401,7 +420,7 @@ impl ClaudeAgentSession { for chunk in message.content.chunks() { match chunk { ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - if !cancellation_state.borrow().is_confirmed() { + if !turn_state.borrow().is_cancelled() { thread .update(cx, |thread, cx| { thread.push_user_content_block(text.into(), cx) @@ -420,10 +439,7 @@ impl ClaudeAgentSession { acp::ToolCallUpdate { id: acp::ToolCallId(tool_use_id.into()), fields: acp::ToolCallUpdateFields { - status: if cancellation_state - .borrow() - .is_confirmed() - { + status: if turn_state.borrow().is_cancelled() { // Do not set to completed if turn was cancelled None } else { @@ -555,37 +571,38 @@ impl ClaudeAgentSession { result, .. } => { - if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() { - if is_error - || (subtype == ResultErrorType::ErrorDuringExecution - && !cancellation_state.borrow().is_confirmed()) - { - end_turn_tx - .send(Err(anyhow!( - "Error: {}", - result.unwrap_or_else(|| subtype.to_string()) - ))) - .ok(); - } else { - let stop_reason = match subtype { - ResultErrorType::Success => acp::StopReason::EndTurn, - ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, - ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled, - }; - end_turn_tx - .send(Ok(acp::PromptResponse { stop_reason })) - .ok(); - } + let turn_state = turn_state.take(); + let was_cancelled = turn_state.is_cancelled(); + let Some(end_turn_tx) = turn_state.end_tx() else { + debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn"); + return; + }; + + if is_error || (!was_cancelled && subtype == ResultErrorType::ErrorDuringExecution) + { + end_turn_tx + .send(Err(anyhow!( + "Error: {}", + result.unwrap_or_else(|| subtype.to_string()) + ))) + .ok(); + } else { + let stop_reason = match subtype { + ResultErrorType::Success => acp::StopReason::EndTurn, + ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, + ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled, + }; + end_turn_tx + .send(Ok(acp::PromptResponse { stop_reason })) + .ok(); } } SdkMessage::ControlResponse { response } => { if matches!(response.subtype, ResultErrorType::Success) { - let mut cancellation_state = cancellation_state.borrow_mut(); - if let CancellationState::Requested { request_id } = &*cancellation_state - && request_id == &response.request_id - { - *cancellation_state = CancellationState::Confirmed; - } + let new_state = turn_state.take().confirm_cancellation(&response.request_id); + turn_state.replace(new_state); + } else { + log::error!("Control response error: {:?}", response); } } SdkMessage::System { .. } => {} From 3925aa9b29e7568525e9d6b90254ab09cdf4e039 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 11:37:24 -0300 Subject: [PATCH 04/11] Remove CI workflow for now --- .github/workflows/agent_servers_e2e.yml | 118 ------------------------ 1 file changed, 118 deletions(-) delete mode 100644 .github/workflows/agent_servers_e2e.yml diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml deleted file mode 100644 index 9f58266f9d..0000000000 --- a/.github/workflows/agent_servers_e2e.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Agent Servers E2E Tests - -on: - schedule: - # Run once a day at 2:00 AM UTC - - cron: "0 2 * * *" - - push: - branches: - - main - - "v[0-9]+.[0-9]+.x" - paths: - - "crates/agent_servers/**" - - "crates/acp_thread/**" - - ".github/workflows/agent_servers_e2e.yml" - - pull_request: - branches: - - "**" - paths: - - "crates/agent_servers/**" - - "crates/acp_thread/**" - - ".github/workflows/agent_servers_e2e.yml" - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - -jobs: - e2e-tests: - name: Run Agent Servers E2E Tests - if: github.repository_owner == 'zed-industries' - timeout-minutes: 60 - runs-on: - - buildjet-16vcpu-ubuntu-2204 - - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Checkout gemini-cli repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - repository: zed-industries/gemini-cli - ref: migrate-acp - path: gemini-cli - clean: false - - - name: Install Rust - shell: bash -euxo pipefail {0} - run: | - cargo install cargo-nextest --locked - - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Install Claude Code CLI - shell: bash -euxo pipefail {0} - run: | - npm install -g @anthropic-ai/claude-code - # Verify installation - which claude || echo "Claude CLI not found in PATH" - # Skip authentication if API key is not set (tests may use mock) - if [ -n "$ANTHROPIC_API_KEY" ]; then - echo "Anthropic API key is configured" - fi - - - name: Install and setup Gemini CLI - shell: bash -euxo pipefail {0} - run: | - # Install globally for potential fallback - npm install -g @google/gemini-cli - - # Also install dependencies for local gemini-cli repo - cd gemini-cli/packages/cli - npm install - cd - - - # Verify installations - which gemini || echo "Gemini CLI not found in PATH" - # Skip authentication if API key is not set (tests may use mock) - if [ -n "$GEMINI_API_KEY" ]; then - echo "Gemini API key is configured" - fi - - - name: Limit target directory size - shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 - - - name: Run E2E tests - shell: bash -euxo pipefail {0} - run: | - cargo nextest run \ - --package agent_servers \ - --features e2e \ - --no-fail-fast - - - name: Upload test results - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: | - target/nextest/default/*.xml - retention-days: 7 From cace7de723695acccc8a4cbfc9cbb31bea87fca3 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 11:43:45 -0300 Subject: [PATCH 05/11] Fix double take --- crates/agent_servers/src/claude.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index c00c3877cb..c65508f152 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -269,7 +269,7 @@ impl AgentConnection for ClaudeAgentConnection { let request_id = new_request_id(); let turn_state = session.turn_state.take(); - let TurnState::InProgress { end_tx } = session.turn_state.take() else { + let TurnState::InProgress { end_tx } = turn_state else { // Already cancelled or idle, put it back session.turn_state.replace(turn_state); return; From 49ef4b50243210603792eb2d89ded8aa4421d691 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 11:58:49 -0300 Subject: [PATCH 06/11] Run claude e2e tests in CI - attempt 1 --- .github/workflows/agent_servers_e2e.yml | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/agent_servers_e2e.yml diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml new file mode 100644 index 0000000000..af158e402e --- /dev/null +++ b/.github/workflows/agent_servers_e2e.yml @@ -0,0 +1,122 @@ +name: Agent Servers E2E Tests + +on: + schedule: + - cron: "0 12 * * *" + + push: + branches: + - as-e2e-ci + + pull_request: + branches: + - "**" + paths: + - "crates/agent_servers/**" + - "crates/acp_thread/**" + - ".github/workflows/agent_servers_e2e.yml" + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + +jobs: + e2e-tests: + name: Run Agent Servers E2E Tests + if: github.repository_owner == 'zed-industries' + timeout-minutes: 10 + runs-on: + - ubuntu-latest + + steps: + - name: Add Rust to the PATH + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + # - name: Checkout gemini-cli repo + # uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + # with: + # repository: zed-industries/gemini-cli + # ref: migrate-acp + # path: gemini-cli + # clean: false + + - name: Cache dependencies + uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-provider: "buildjet" + + - name: Install Linux dependencies + run: ./script/linux + + - name: Configure CI + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + + - name: Install Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "18" + + - name: Install Claude Code CLI + shell: bash -euxo pipefail {0} + run: | + npm install -g @anthropic-ai/claude-code + # Verify installation + which claude || echo "Claude CLI not found in PATH" + # Skip authentication if API key is not set (tests may use mock) + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "Anthropic API key is configured" + fi + + # - name: Install and setup Gemini CLI + # shell: bash -euxo pipefail {0} + # run: | + # # Also install dependencies for local gemini-cli repo + # pushd gemini-cli + # npm install + # npm run build + # popd + + # # Verify installations + # which gemini || echo "Gemini CLI not found in PATH" + # # Skip authentication if API key is not set (tests may use mock) + # if [ -n "$GEMINI_API_KEY" ]; then + # echo "Gemini API key is configured" + # fi + + - name: Limit target directory size + shell: bash -euxo pipefail {0} + run: script/clear-target-dir-if-larger-than 100 + + - name: Run E2E tests + shell: bash -euxo pipefail {0} + run: | + cargo nextest run \ + --package agent_servers \ + --features e2e \ + --no-fail-fast \ + claude + + # Even the Linux runner is not stateful, in theory there is no need to do this cleanup. + # But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code + # to clean up the config file, I’ve included the cleanup code here as a precaution. + # While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution. + - name: Clean CI config file + if: always() + run: rm -rf ./../.cargo From 5db22c9440620ed59a7206044fc77de860b9ecf5 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 12:02:51 -0300 Subject: [PATCH 07/11] Install nextest --- .github/workflows/agent_servers_e2e.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml index af158e402e..131cdf553e 100644 --- a/.github/workflows/agent_servers_e2e.yml +++ b/.github/workflows/agent_servers_e2e.yml @@ -104,6 +104,11 @@ jobs: shell: bash -euxo pipefail {0} run: script/clear-target-dir-if-larger-than 100 + - name: Install nextest + shell: bash -euxo pipefail {0} + run: | + cargo install cargo-nextest --locked + - name: Run E2E tests shell: bash -euxo pipefail {0} run: | From d97e15dcaf0f90035670a2cd8305c2b977c9eef3 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 12:24:03 -0300 Subject: [PATCH 08/11] Raise timeout --- .github/workflows/agent_servers_e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml index 131cdf553e..88f2434fd4 100644 --- a/.github/workflows/agent_servers_e2e.yml +++ b/.github/workflows/agent_servers_e2e.yml @@ -33,7 +33,7 @@ jobs: e2e-tests: name: Run Agent Servers E2E Tests if: github.repository_owner == 'zed-industries' - timeout-minutes: 10 + timeout-minutes: 20 runs-on: - ubuntu-latest From f1af9d5fbd2bdf12d3c9b720c74daa3dd4ab282a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 12:27:58 -0300 Subject: [PATCH 09/11] Use same machine as eval --- .github/workflows/agent_servers_e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml index 88f2434fd4..e79240d215 100644 --- a/.github/workflows/agent_servers_e2e.yml +++ b/.github/workflows/agent_servers_e2e.yml @@ -35,7 +35,7 @@ jobs: if: github.repository_owner == 'zed-industries' timeout-minutes: 20 runs-on: - - ubuntu-latest + - buildjet-16vcpu-ubuntu-2204 steps: - name: Add Rust to the PATH From 2ecb5b2ff6edcc369c5afafcd0d1db3074c6965a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 13:35:21 -0300 Subject: [PATCH 10/11] Use tempfile tempdir instead of hardcoding /private/tmp --- crates/agent_servers/src/claude.rs | 5 ++++- crates/agent_servers/src/e2e_tests.rs | 28 ++++++++++++++++++--------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index c65508f152..031ad7727d 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -893,9 +893,10 @@ pub(crate) mod tests { #[cfg_attr(not(feature = "e2e"), ignore)] async fn test_todo_plan(cx: &mut TestAppContext) { let fs = e2e_tests::init_test(cx).await; + let tempdir = tempfile::tempdir().unwrap(); let project = Project::test(fs, [], cx).await; let thread = - e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await; + e2e_tests::new_test_thread(ClaudeCode, project.clone(), tempdir.path(), cx).await; thread .update(cx, |thread, cx| { @@ -949,6 +950,8 @@ pub(crate) mod tests { )); assert_eq!(thread.plan().entries.len(), entries_len); }); + + drop(tempdir); } #[test] diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index ec6ca29b9d..bab1d22a9a 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -13,12 +13,12 @@ use gpui::{Entity, TestAppContext}; use indoc::indoc; use project::{FakeFs, Project}; use settings::{Settings, SettingsStore}; -use util::path; pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) { let fs = init_test(cx).await; + let tempdir = tempfile::tempdir().unwrap(); let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; thread .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) @@ -40,6 +40,8 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont AgentThreadEntry::AssistantMessage(_) )); }); + + drop(tempdir); } pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) { @@ -118,7 +120,7 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; thread .update(cx, |thread, cx| { @@ -156,8 +158,9 @@ pub async fn test_tool_call_with_permission( cx: &mut TestAppContext, ) { let fs = init_test(cx).await; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let tempdir = tempfile::tempdir().unwrap(); + let project = Project::test(fs, [tempdir.path()], cx).await; + let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -239,13 +242,15 @@ pub async fn test_tool_call_with_permission( "Expected content to contain 'Hello'" ); }); + + drop(tempdir); } pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) { let fs = init_test(cx).await; - - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let tempdir = tempfile::tempdir().unwrap(); + let project = Project::test(fs, [tempdir.path()], cx).await; + let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; let _ = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -308,12 +313,15 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon AgentThreadEntry::AssistantMessage(..), )) }); + + drop(tempdir); } pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) { let fs = init_test(cx).await; + let tempdir = tempfile::tempdir().unwrap(); let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; thread .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx)) @@ -329,6 +337,8 @@ pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestA cx.executor().run_until_parked(); assert!(!weak_thread.is_upgradable()); + + drop(tempdir); } #[macro_export] From 5de2c28f7533894d92d2e977047b2302802c7fec Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 14:59:45 -0300 Subject: [PATCH 11/11] Build Zed --- .github/workflows/agent_servers_e2e.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/agent_servers_e2e.yml b/.github/workflows/agent_servers_e2e.yml index e79240d215..b2c518409e 100644 --- a/.github/workflows/agent_servers_e2e.yml +++ b/.github/workflows/agent_servers_e2e.yml @@ -109,6 +109,11 @@ jobs: run: | cargo install cargo-nextest --locked + - name: Build Zed + shell: bash -euxo pipefail {0} + run: | + cargo build + - name: Run E2E tests shell: bash -euxo pipefail {0} run: |