diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index dfc451df635..b96727f9ae4 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -1106,8 +1106,93 @@ pub struct AIBlock { struct EmbeddedCodeEditorView { view: ViewHandle, language: Option, - length: usize, + /// The full text most recently applied to `view`. Used to decide whether + /// the next streamed update is a content-stable append/truncate of this + /// text (safe to apply incrementally) or an unrelated rewrite that must + /// reset the buffer instead (see `streamed_code_update`). + rendered_code: String, } + +/// How a streamed update should be applied to a buffer that currently holds +/// `previous_value`, given the newly received `new_value`. +/// +/// Streamed AI code blocks (and, e.g., streamed MCP/requested-command text) +/// are assumed to only change at the end of the string: either a suffix was +/// appended, or a few trailing bytes (typically a partially-received closing +/// code-fence marker, e.g. `` ` `` `` ` ``) were removed. Under that +/// assumption, `new_value` is a byte-for-byte extension of `previous_value` +/// (grow) or `previous_value` is a byte-for-byte extension of `new_value` +/// (shrink). But a streamed rewrite is not always a clean append/truncate -- +/// e.g. a non-prefix rewrite from the server, or a same-length correction -- +/// so [`streamed_code_update`] verifies the actual byte contents (not just +/// lengths) before deciding to append or truncate, and asks the caller to +/// reset the buffer wholesale otherwise (see `apply_streamed_code_update`). +/// This also makes it safe with respect to UTF-8 char boundaries: comparing +/// and slicing by matched content, rather than by a raw byte offset that +/// could land mid-character, can never panic. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(super) enum StreamedCodeUpdate<'a> { + /// Append this suffix to the end of the existing buffer. + Append(&'a str), + /// `new_value` is not a content-stable append/truncate of + /// `previous_value` (e.g. a non-prefix rewrite, or a same-length + /// rewrite with different content); the buffer should be reset to the + /// full `new_value` instead of patched in place. + Reset, + /// Truncate the buffer so it holds exactly `new_value`. + Truncate, + /// `new_value` is unchanged from `previous_value`. + NoOp, +} + +/// Extracted for unit testing. See [`StreamedCodeUpdate`] for the rationale. +pub(super) fn streamed_code_update<'a>( + new_value: &'a str, + previous_value: &str, +) -> StreamedCodeUpdate<'a> { + match new_value.len().cmp(&previous_value.len()) { + Ordering::Greater => { + if let Some(suffix) = new_value.strip_prefix(previous_value) { + StreamedCodeUpdate::Append(suffix) + } else { + StreamedCodeUpdate::Reset + } + } + Ordering::Less => { + if previous_value.starts_with(new_value) { + StreamedCodeUpdate::Truncate + } else { + StreamedCodeUpdate::Reset + } + } + Ordering::Equal => { + if new_value == previous_value { + StreamedCodeUpdate::NoOp + } else { + StreamedCodeUpdate::Reset + } + } + } +} + +/// Applies a streamed code update to `view`, given the full text (`previous_code`) +/// most recently rendered into it. Shared by `AIBlock` and `CLISubagentView`, +/// whose code-streaming logic is otherwise identical. +fn apply_streamed_code_update( + view: &CodeEditorView, + code: &str, + previous_code: &str, + ctx: &mut ViewContext, +) { + match streamed_code_update(code, previous_code) { + StreamedCodeUpdate::Append(suffix) => view.append_at_end(suffix, ctx), + StreamedCodeUpdate::Reset => view.reset(InitialBufferState::plain_text(code), ctx), + StreamedCodeUpdate::Truncate => view.truncate(code.len(), ctx), + StreamedCodeUpdate::NoOp => return, + } + ctx.notify(); +} + /// Builds the authenticated Oz run-page URL for a recording artifact. /// /// The task ID is assigned to the conversation by the server when the run @@ -3041,18 +3126,11 @@ impl AIBlock { // received the ``` end marker. // Ex: Iteration 57: "a += 12\n``" // Ex: Iteration 58: "a += 12" - match code.len().cmp(&embedded_view.length) { - Ordering::Greater => { - view.append_at_end(&code[embedded_view.length..], ctx); - ctx.notify(); - } - Ordering::Less => { - view.truncate(code.len(), ctx); - ctx.notify(); - } - Ordering::Equal => {} - } - embedded_view.length = code.len(); + // + // See `apply_streamed_code_update`/`streamed_code_update`: a non-prefix + // rewrite resets the buffer instead of corrupting it. + apply_streamed_code_update(view, code, &embedded_view.rendered_code, ctx); + embedded_view.rendered_code = code.to_string(); }); } None => { @@ -3112,7 +3190,7 @@ impl AIBlock { self.code_editor_views.push(EmbeddedCodeEditorView { view, language: language.clone(), - length: code.len(), + rendered_code: code.to_string(), }); } } diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 6f4c9954062..e1fdbc0fbcd 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -1,4 +1,3 @@ -use std::cmp::Ordering; use std::path::Path; use std::rc::Rc; use std::sync::Arc; @@ -45,7 +44,9 @@ use super::view_impl::common::{ render_failed_output, render_informational_footer, render_text_sections, }; use super::view_impl::output::are_all_text_sections_empty; -use super::{EmbeddedCodeEditorView, SecretRedactionState, TableSectionHandles}; +use super::{ + EmbeddedCodeEditorView, SecretRedactionState, TableSectionHandles, apply_streamed_code_update, +}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::icons::yellow_stop_icon; use crate::ai::agent::task::TaskId; @@ -746,18 +747,11 @@ impl CLISubagentView { // received the ``` end marker. // Ex: Iteration 57: "a += 12\n``" // Ex: Iteration 58: "a += 12" - match code.len().cmp(&embedded_view.length) { - Ordering::Greater => { - view.append_at_end(&code[embedded_view.length..], ctx); - ctx.notify(); - } - Ordering::Less => { - view.truncate(code.len(), ctx); - ctx.notify(); - } - Ordering::Equal => {} - } - embedded_view.length = code.len(); + // + // See `apply_streamed_code_update`/`streamed_code_update`: a non-prefix + // rewrite resets the buffer instead of corrupting it. + apply_streamed_code_update(view, code, &embedded_view.rendered_code, ctx); + embedded_view.rendered_code = code.to_string(); }); } None => { @@ -804,7 +798,7 @@ impl CLISubagentView { self.code_editor_views.push(EmbeddedCodeEditorView { view, language: Default::default(), - length: code.len(), + rendered_code: code.to_string(), }); self.code_editor_buttons.push(Default::default()); } diff --git a/app/src/ai/blocklist/block_tests.rs b/app/src/ai/blocklist/block_tests.rs index fcf6a21d05a..707a3d564cf 100644 --- a/app/src/ai/blocklist/block_tests.rs +++ b/app/src/ai/blocklist/block_tests.rs @@ -1,33 +1,48 @@ use std::path::PathBuf; +use std::sync::Arc; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode}; use ai::skills::SkillReference; use settings::Setting; use warp_core::channel::ChannelState; +use warp_core::ui::appearance::Appearance; +use warp_editor::render::element::VerticalExpansionBehavior; use warp_util::local_or_remote_path::LocalOrRemotePath; #[cfg(feature = "local_fs")] use warp_util::path::LineAndColumnArg; -use warpui::{App, SingletonEntity}; +use warpui::platform::WindowStyle; +use warpui::{App, SingletonEntity, ViewHandle}; #[cfg(feature = "local_fs")] use super::{AIBlockEvent, open_code_action_event}; use super::{ - CollapsibleElementState, CollapsibleExpansionState, UserAvatarInfo, + CodeEditorRenderOptions, CodeEditorView, CollapsibleElementState, CollapsibleExpansionState, + StreamedCodeUpdate, UserAvatarInfo, apply_streamed_code_update, default_collapsible_state_for_orchestration_action, default_collapsible_state_for_orchestration_message, received_message_collapsible_id, - recording_artifact_view_url, user_avatar_info_for_conversation_creator, + recording_artifact_view_url, streamed_code_update, user_avatar_info_for_conversation_creator, }; +use crate::AuthStateProvider; use crate::ai::agent::{AIAgentActionType, StartAgentExecutionMode}; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::action_model::{ compose_run_agents_child_prompt, run_agents_to_start_agent_mode, }; use crate::auth::UserUid; +use crate::cloud_object::model::persistence::CloudModel; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; +use crate::notebooks::editor::keys::NotebookKeybindings; +use crate::server::server_api::team::MockTeamClient; +use crate::server::server_api::workspace::MockWorkspaceClient; use crate::settings::{AISettings, OrchestrationMessageDisplayMode}; +use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::test_util::settings::initialize_settings_for_tests; +use crate::vim_registers::VimRegisters; +use crate::workspace::ActiveSession; +use crate::workspace::sync_inputs::SyncedInputState; use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles}; +use crate::workspaces::user_workspaces::UserWorkspaces; #[test] fn reasoning_auto_collapses_when_user_has_not_manually_toggled() { @@ -133,6 +148,172 @@ fn recording_artifact_view_url_requires_task_id() { assert_eq!(recording_artifact_view_url(None, "recording-123"), None); } +#[test] +fn streamed_code_update_appends_suffix_when_new_value_extends_previous() { + assert_eq!( + streamed_code_update("abc", "ab"), + StreamedCodeUpdate::Append("c") + ); +} + +#[test] +fn streamed_code_update_appends_multibyte_suffix_from_empty() { + // "你" is a 3-byte character; growing from an empty buffer must not + // require (or incorrectly reject on) a char-boundary check. + assert_eq!( + streamed_code_update("你b", ""), + StreamedCodeUpdate::Append("你b") + ); +} + +#[test] +fn streamed_code_update_resets_on_boundary_aligned_non_prefix_grow() { + // Regression test for a follow-up finding on APP-5288: "abc" -> "XYZq" has a + // byte-boundary-valid split offset (3), but "XYZq" does not actually extend + // "abc". A boundary-only check would append "q" and corrupt the buffer to + // "abcq" instead of resetting it to "XYZq". + assert_eq!( + streamed_code_update("XYZq", "abc"), + StreamedCodeUpdate::Reset + ); +} + +#[test] +fn streamed_code_update_resets_on_non_prefix_grow_with_multibyte_content() { + // Byte offset 2 is a valid char boundary in "你b" (4 bytes: 3 for 你, 1 for + // b), but "你b" does not extend "ab" -- must reset rather than panic or + // silently corrupt the buffer. + assert_eq!(streamed_code_update("你b", "ab"), StreamedCodeUpdate::Reset); +} + +#[test] +fn streamed_code_update_truncates_when_previous_extends_new_value() { + assert_eq!( + streamed_code_update("a += 12", "a += 12\n``"), + StreamedCodeUpdate::Truncate + ); +} + +#[test] +fn streamed_code_update_resets_on_non_prefix_shrink() { + // A shorter rewrite that isn't actually a prefix of the previous text must + // reset rather than truncate to unrelated, stale content. + assert_eq!( + streamed_code_update("xyz", "abcdef"), + StreamedCodeUpdate::Reset + ); +} + +#[test] +fn streamed_code_update_resets_on_equal_length_rewrite() { + // Same-length rewrites are a form of non-prefix update that a length-only + // check misses entirely (an `Ordering::Equal` naively short-circuits to a + // no-op). + assert_eq!( + streamed_code_update("xyz", "abc"), + StreamedCodeUpdate::Reset + ); +} + +#[test] +fn streamed_code_update_is_noop_when_unchanged() { + assert_eq!( + streamed_code_update("a += 12", "a += 12"), + StreamedCodeUpdate::NoOp + ); +} + +/// Constructs a bare `CodeEditorView` in a fresh window, wired up with the +/// minimal singleton mocks it depends on (mirroring +/// `code::editor::view::view_tests::initialize_editor`). Used to exercise +/// `apply_streamed_code_update` -- the exact function `AIBlock` and +/// `CLISubagentView` call while streaming -- against a real buffer. +fn test_code_editor(app: &mut App) -> ViewHandle { + initialize_settings_for_tests(app); + app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| SyncedInputState::mock()); + app.add_singleton_model(|_| VimRegisters::new()); + app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| ActiveSession::default()); + app.add_singleton_model(NotebookKeybindings::new); + + let team_client_mock = Arc::new(MockTeamClient::new()); + let workspace_client_mock = Arc::new(MockWorkspaceClient::new()); + app.add_singleton_model(|ctx| { + UserWorkspaces::mock( + team_client_mock.clone(), + workspace_client_mock.clone(), + vec![], + ctx, + ) + }); + + let (_window, editor_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| { + CodeEditorView::new( + None, + None, + CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight), + ctx, + ) + }); + editor_view +} + +#[test] +fn apply_streamed_code_update_through_real_editor_appends_then_resets_on_non_prefix_rewrite() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "abc", "", ctx); + }); + let text = editor.update(&mut app, |view, ctx| view.text(ctx).as_str().to_string()); + assert_eq!(text, "abc"); + + // Boundary-aligned, non-prefix rewrite on an *existing, populated* editor: + // must reset the real buffer to "XYZq", not corrupt it into "abcq". + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "XYZq", "abc", ctx); + }); + let text = editor.update(&mut app, |view, ctx| view.text(ctx).as_str().to_string()); + assert_eq!(text, "XYZq"); + }); +} + +#[test] +fn apply_streamed_code_update_through_real_editor_truncates_on_valid_shrink() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "a += 12\n``", "", ctx); + }); + + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "a += 12", "a += 12\n``", ctx); + }); + let text = editor.update(&mut app, |view, ctx| view.text(ctx).as_str().to_string()); + assert_eq!(text, "a += 12"); + }); +} + +#[test] +fn apply_streamed_code_update_through_real_editor_resets_on_non_prefix_shrink() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "abcdef", "", ctx); + }); + + editor.update(&mut app, |view, ctx| { + apply_streamed_code_update(view, "xyz", "abcdef", ctx); + }); + let text = editor.update(&mut app, |view, ctx| view.text(ctx).as_str().to_string()); + assert_eq!(text, "xyz"); + }); +} + #[cfg(feature = "local_fs")] #[test] fn open_code_action_routes_links_to_configured_editor_and_non_links_to_warp() { diff --git a/app/src/ai/blocklist/inline_action/requested_command.rs b/app/src/ai/blocklist/inline_action/requested_command.rs index 9d1d88fb006..7d4b6393232 100644 --- a/app/src/ai/blocklist/inline_action/requested_command.rs +++ b/app/src/ai/blocklist/inline_action/requested_command.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::cmp::{Ordering, PartialEq}; +use std::cmp::PartialEq; use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; @@ -45,7 +45,9 @@ use crate::ai::blocklist::block::view_impl::{ CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN, render_autonomy_checkbox_setting_speedbump_footer, render_citation, render_citation_chips, }; -use crate::ai::blocklist::block::{AIBlockAction, AutonomySettingSpeedbump}; +use crate::ai::blocklist::block::{ + AIBlockAction, AutonomySettingSpeedbump, StreamedCodeUpdate, streamed_code_update, +}; use crate::ai::blocklist::inline_action::inline_action_header::{ ExpandedConfig, HeaderConfig, INLINE_ACTION_HORIZONTAL_PADDING, InteractionMode, RightClickConfig, @@ -1048,54 +1050,25 @@ impl RequestedCommandView { /// This is to reduce flicker. /// /// If the command length is shorter than the previous update, then the command is truncated to the given byte length. + /// If `command` is not a content-stable append/truncate of the previous command text (e.g. a + /// non-prefix rewrite from the server, or a same-length correction), `command_text` -- and, if + /// an editor already exists, the editor -- are reset wholesale to `command` instead. pub fn apply_streamed_update(&mut self, command: &str, ctx: &mut ViewContext) { - match command.len().cmp(&self.command_text.len()) { - Ordering::Greater => { - // Check if the existing length falls on a valid UTF-8 character boundary. - let existing_length = self.command_text.len(); - if command.is_char_boundary(existing_length) { - self.command_text.push_str(&command[existing_length..]); - } else { - self.command_text = command.to_string(); - } - } - Ordering::Less => { - self.command_text.truncate(command.len()); - } - Ordering::Equal => {} + let update = streamed_code_update(command, &self.command_text); + match update { + StreamedCodeUpdate::Append(suffix) => self.command_text.push_str(suffix), + StreamedCodeUpdate::Truncate => self.command_text.truncate(command.len()), + StreamedCodeUpdate::Reset => self.command_text = command.to_string(), + StreamedCodeUpdate::NoOp => {} } - // If the editor exists, sync it with the updated command text. + // If the editor exists, sync it with the same decision that was just applied to + // `command_text` above, rather than letting it independently re-derive an update by + // comparing lengths against its own current content (see `apply_streamed_command_editor_update`). if let Some(editor) = &self.editor { + let command_text = self.command_text.clone(); editor.update(ctx, |editor, ctx| { - let editor_length = editor.text(ctx).as_str().len(); - match self.command_text.len().cmp(&editor_length) { - Ordering::Greater => { - let slice_to_append = if self.command_text.is_char_boundary(editor_length) { - &self.command_text[editor_length..] - } else { - editor.truncate(0, ctx); - &self.command_text - }; - // TODO(Simon): The first insertion into an empty Buffer creates a trailing newline. - // If the requested command is streamed in in a single chunk, then there will - // be an extra newline rendered at the end of the `CodeEditorView`. This is likely - // caused by an initial insertion bug somewhere in the `Buffer` logic. - // - // To reproduce this bug, simply clear the buffer and type in two letters. You'll - // notice that a newline is created on the first letter, but removed on the second. - // The temporary workaround is to append an empty string to the end of each chunk, - // which acts as the second insertion that clears the trailing newline. - editor.system_append_autoscroll_vertical_only(slice_to_append, ctx); - editor.system_append_autoscroll_vertical_only("", ctx); - ctx.notify(); - } - Ordering::Less => { - editor.truncate(self.command_text.len(), ctx); - ctx.notify(); - } - Ordering::Equal => {} - } + apply_streamed_command_editor_update(editor, update, &command_text, ctx); }); } } @@ -2180,6 +2153,46 @@ impl RequestedCommand { } } +/// Applies a [`StreamedCodeUpdate`] decision -- already computed and applied +/// to `command_text` by the caller -- to the [`CodeEditorView`] that mirrors +/// it, so the two never diverge. Used by +/// [`RequestedCommandView::apply_streamed_update`] instead of having the +/// editor independently re-derive its own update by comparing lengths +/// against its current content: that approach has the same "length/boundary +/// lines up but content diverged" bug as `command_text` itself (APP-5288). +fn apply_streamed_command_editor_update( + editor: &CodeEditorView, + update: StreamedCodeUpdate, + command_text: &str, + ctx: &mut ViewContext, +) { + match update { + StreamedCodeUpdate::Append(suffix) => { + // TODO(Simon): The first insertion into an empty Buffer creates a trailing newline. + // If the requested command is streamed in in a single chunk, then there will + // be an extra newline rendered at the end of the `CodeEditorView`. This is likely + // caused by an initial insertion bug somewhere in the `Buffer` logic. + // + // To reproduce this bug, simply clear the buffer and type in two letters. You'll + // notice that a newline is created on the first letter, but removed on the second. + // The temporary workaround is to append an empty string to the end of each chunk, + // which acts as the second insertion that clears the trailing newline. + editor.system_append_autoscroll_vertical_only(suffix, ctx); + editor.system_append_autoscroll_vertical_only("", ctx); + } + StreamedCodeUpdate::Reset => { + editor.truncate(0, ctx); + if !command_text.is_empty() { + editor.system_append_autoscroll_vertical_only(command_text, ctx); + editor.system_append_autoscroll_vertical_only("", ctx); + } + } + StreamedCodeUpdate::Truncate => editor.truncate(command_text.len(), ctx), + StreamedCodeUpdate::NoOp => return, + } + ctx.notify(); +} + /// Formats the command text to truncate at the first newline and add an ellipsis. /// Extracted for unit testing. pub fn format_command_text(text: &str) -> String { diff --git a/app/src/ai/blocklist/inline_action/requested_command_tests.rs b/app/src/ai/blocklist/inline_action/requested_command_tests.rs index c7bfaf2fd3c..d2fcf767ed3 100644 --- a/app/src/ai/blocklist/inline_action/requested_command_tests.rs +++ b/app/src/ai/blocklist/inline_action/requested_command_tests.rs @@ -1,6 +1,28 @@ -//! Unit tests for format_command_text in requested_command.rs +//! Unit tests for format_command_text and the streamed-update helpers in requested_command.rs -use super::{format_command_text, mcp_blocked_title_text, mcp_viewing_detail_title_text}; +use std::sync::Arc; + +use warp_core::ui::appearance::Appearance; +use warp_editor::render::element::VerticalExpansionBehavior; +use warpui::platform::WindowStyle; +use warpui::{App, ViewHandle}; + +use super::{ + CodeEditorRenderOptions, CodeEditorView, StreamedCodeUpdate, + apply_streamed_command_editor_update, format_command_text, mcp_blocked_title_text, + mcp_viewing_detail_title_text, streamed_code_update, +}; +use crate::AuthStateProvider; +use crate::cloud_object::model::persistence::CloudModel; +use crate::notebooks::editor::keys::NotebookKeybindings; +use crate::server::server_api::team::MockTeamClient; +use crate::server::server_api::workspace::MockWorkspaceClient; +use crate::settings_view::keybindings::KeybindingChangedNotifier; +use crate::test_util::settings::initialize_settings_for_tests; +use crate::vim_registers::VimRegisters; +use crate::workspace::ActiveSession; +use crate::workspace::sync_inputs::SyncedInputState; +use crate::workspaces::user_workspaces::UserWorkspaces; #[test] fn single_line_without_newline_is_unchanged_ascii() { @@ -71,6 +93,129 @@ fn newline_then_multibyte_results_in_ellipsis_only() { assert_eq!(reconstructed, output); } +/// Constructs a bare `CodeEditorView`, mirroring +/// `ai::blocklist::block_tests::test_code_editor`. Used to exercise +/// `apply_streamed_command_editor_update` -- the exact function +/// `RequestedCommandView::apply_streamed_update` uses to keep its editor in +/// sync with `command_text` -- against a real buffer. +fn test_code_editor(app: &mut App) -> ViewHandle { + initialize_settings_for_tests(app); + app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| SyncedInputState::mock()); + app.add_singleton_model(|_| VimRegisters::new()); + app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| ActiveSession::default()); + app.add_singleton_model(NotebookKeybindings::new); + + let team_client_mock = Arc::new(MockTeamClient::new()); + let workspace_client_mock = Arc::new(MockWorkspaceClient::new()); + app.add_singleton_model(|ctx| { + UserWorkspaces::mock( + team_client_mock.clone(), + workspace_client_mock.clone(), + vec![], + ctx, + ) + }); + + let (_window, editor_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| { + CodeEditorView::new( + None, + None, + CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight), + ctx, + ) + }); + editor_view +} + +#[test] +fn apply_streamed_command_editor_update_appends_suffix() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + let update = streamed_code_update("echo hi", ""); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, update, "echo hi", ctx); + }); + let text = editor.update(&mut app, |editor, ctx| { + editor.text(ctx).as_str().to_string() + }); + assert_eq!(text, "echo hi"); + }); +} + +#[test] +fn apply_streamed_command_editor_update_resets_existing_editor_on_non_prefix_rewrite() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + let first_update = streamed_code_update("abc", ""); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, first_update, "abc", ctx); + }); + let text = editor.update(&mut app, |editor, ctx| { + editor.text(ctx).as_str().to_string() + }); + assert_eq!(text, "abc"); + + // Regression test for a follow-up finding on APP-5288: a boundary-aligned, + // non-prefix rewrite on an *already-populated* editor must reset it to + // "XYZq", not corrupt it into "abcq" the way a length-only sync would. + let second_update = streamed_code_update("XYZq", "abc"); + assert_eq!(second_update, StreamedCodeUpdate::Reset); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, second_update, "XYZq", ctx); + }); + let text = editor.update(&mut app, |editor, ctx| { + editor.text(ctx).as_str().to_string() + }); + assert_eq!(text, "XYZq"); + }); +} + +#[test] +fn apply_streamed_command_editor_update_truncates_on_valid_shrink() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + let first_update = streamed_code_update("cargo build\n``", ""); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, first_update, "cargo build\n``", ctx); + }); + + let second_update = streamed_code_update("cargo build", "cargo build\n``"); + assert_eq!(second_update, StreamedCodeUpdate::Truncate); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, second_update, "cargo build", ctx); + }); + let text = editor.update(&mut app, |editor, ctx| { + editor.text(ctx).as_str().to_string() + }); + assert_eq!(text, "cargo build"); + }); +} + +#[test] +fn apply_streamed_command_editor_update_is_noop_when_unchanged() { + App::test((), |mut app| async move { + let editor = test_code_editor(&mut app); + let first_update = streamed_code_update("echo hi", ""); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, first_update, "echo hi", ctx); + }); + + let second_update = streamed_code_update("echo hi", "echo hi"); + assert_eq!(second_update, StreamedCodeUpdate::NoOp); + editor.update(&mut app, |editor, ctx| { + apply_streamed_command_editor_update(editor, second_update, "echo hi", ctx); + }); + let text = editor.update(&mut app, |editor, ctx| { + editor.text(ctx).as_str().to_string() + }); + assert_eq!(text, "echo hi"); + }); +} + #[test] fn mcp_blocked_title_surfaces_tool_and_server_when_known() { assert_eq!(