From e83eed712bbe8c7ea5d74d7fde0baa272a39de4c Mon Sep 17 00:00:00 2001 From: Oz Date: Tue, 11 Aug 2026 13:39:44 +0000 Subject: [PATCH 1/3] Fix char-boundary panic in WorkflowDataSource preview truncation WorkflowDataSource::run_query truncated the workflow description preview with a hard-coded byte slice (&content_preview[..197]), which panics when a multi-byte UTF-8 character straddles byte 197 (e.g. workflow content containing emoji, CJK, or accented Latin text over 200 bytes). Replace the raw slice with the existing safe_truncate helper (also used by the sibling code/notebooks/rules/workflows search_item.rs renderers), and add a regression test with content matching the Sentry-reported failure mode. Fixes APP-5287. Co-Authored-By: Warp Agent --- .../ai_context_menu/workflows/data_source.rs | 12 +- .../workflows/data_source_tests.rs | 126 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/src/search/ai_context_menu/workflows/data_source_tests.rs diff --git a/app/src/search/ai_context_menu/workflows/data_source.rs b/app/src/search/ai_context_menu/workflows/data_source.rs index e1d5f824793..210f11f8d25 100644 --- a/app/src/search/ai_context_menu/workflows/data_source.rs +++ b/app/src/search/ai_context_menu/workflows/data_source.rs @@ -5,6 +5,7 @@ use super::search_item::WorkflowSearchItem; use crate::cloud_object::CloudModelType; use crate::cloud_object::model::persistence::CloudModel; use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction; +use crate::search::ai_context_menu::safe_truncate; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource}; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -66,7 +67,12 @@ impl SyncDataSource for WorkflowDataSource { None } else { Some(if content_preview.len() > 200 { - format!("{}...", &content_preview[..197]) + // Truncate at a valid UTF-8 char boundary to avoid panicking when a + // multi-byte character straddles the byte-197 cut point. + let mut truncated = content_preview; + safe_truncate(&mut truncated, 197); + truncated.push_str("..."); + truncated } else { content_preview }) @@ -129,3 +135,7 @@ impl SyncDataSource for WorkflowDataSource { impl warpui::Entity for WorkflowDataSource { type Event = (); } + +#[cfg(test)] +#[path = "data_source_tests.rs"] +mod tests; diff --git a/app/src/search/ai_context_menu/workflows/data_source_tests.rs b/app/src/search/ai_context_menu/workflows/data_source_tests.rs new file mode 100644 index 00000000000..e92667e6e85 --- /dev/null +++ b/app/src/search/ai_context_menu/workflows/data_source_tests.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use chrono::Utc; +use cloud_object_client::MockObjectClient; +use settings::manager::SettingsManager; +use warpui::{App, SingletonEntity}; + +use crate::NetworkStatus; +use crate::auth::AuthStateProvider; +use crate::cloud_object::model::persistence::CloudModel; +use crate::cloud_object::model::view::CloudViewModel; +use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow}; +use crate::notebooks::manager::NotebookManager; +use crate::search::ai_context_menu::workflows::data_source::WorkflowDataSource; +use crate::search::data_source::Query; +use crate::search::mixer::SyncDataSource; +use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::server::ids::{ServerId, SyncId}; +use crate::server::server_api::ServerApiProvider; +use crate::server::server_api::team::MockTeamClient; +use crate::server::server_api::workspace::MockWorkspaceClient; +use crate::server::sync_queue::SyncQueue; +use crate::settings::AISettings; +use crate::system::SystemStats; +use crate::workflows::workflow::Workflow; +use crate::workflows::{CloudWorkflowModel, WorkflowId}; +use crate::workspaces::team_tester::TeamTesterStatus; +use crate::workspaces::user_profiles::UserProfiles; +use crate::workspaces::user_workspaces::UserWorkspaces; + +fn mock_server_workflow(id: i64, name: &str, query: &str) -> ServerWorkflow { + ServerWorkflow::new( + SyncId::ServerId(WorkflowId::from(id).into()), + CloudWorkflowModel::new(Workflow::AgentMode { + name: name.to_owned(), + query: query.to_owned(), + description: None, + arguments: Vec::new(), + }), + ServerMetadata { + uid: ServerId::default(), + revision: Revision::now(), + metadata_last_updated_ts: Utc::now().into(), + trashed_ts: None, + folder_id: None, + is_welcome_object: false, + creator_uid: None, + last_editor_uid: None, + current_editor_uid: None, + }, + ServerPermissions { + space: Owner::mock_current_user(), + guests: Vec::new(), + anyone_link_sharing: None, + permissions_last_updated_ts: Utc::now().into(), + }, + ) +} + +fn initialize_app(app: &mut App) { + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| SystemStats::new()); + let mock_team_client = Arc::new(MockTeamClient::new()); + let mock_workspace_client = Arc::new(MockWorkspaceClient::new()); + app.add_singleton_model(|ctx| { + UserWorkspaces::mock( + mock_team_client.clone(), + mock_workspace_client.clone(), + vec![], + ctx, + ) + }); + app.add_singleton_model(TeamTesterStatus::new); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx)); + app.add_singleton_model(|_| UserProfiles::new(Vec::new())); + app.add_singleton_model(CloudViewModel::new); + app.add_singleton_model(NotebookManager::mock); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.update(crate::settings::init_and_register_user_preferences); + app.update(AISettings::register_and_subscribe_to_events); +} + +/// Regression test for APP-5287: a workflow whose first three content lines exceed 200 bytes, +/// with a multi-byte character straddling the byte-197 truncation boundary, must not panic and +/// must produce a valid, truncated description. +#[test] +fn run_query_does_not_panic_on_multibyte_content_straddling_truncation_boundary() { + // 196 ASCII bytes followed by a 3-byte CJK character puts the character's bytes at + // indices 196-198, straddling the old hard-coded byte-197 cut point. + let content = format!("{}世界", "a".repeat(196)); + + App::test((), |mut app| async move { + initialize_app(&mut app); + + CloudModel::handle(&app).update(&mut app, |model, ctx| { + model.upsert_from_server_workflow(mock_server_workflow(1, "multibyte", &content), ctx); + }); + + let data_source = WorkflowDataSource::new(); + let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); + + assert_eq!(results.len(), 1); + }) +} + +#[test] +fn short_content_is_not_truncated() { + let content = "short content"; + + App::test((), |mut app| async move { + initialize_app(&mut app); + + CloudModel::handle(&app).update(&mut app, |model, ctx| { + model.upsert_from_server_workflow(mock_server_workflow(1, "short", content), ctx); + }); + + let data_source = WorkflowDataSource::new(); + let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); + + assert_eq!(results.len(), 1); + }) +} From 7b4e63286787a97d5e2d4a853d1bab8d340121a3 Mon Sep 17 00:00:00 2001 From: Oz Date: Tue, 11 Aug 2026 13:50:47 +0000 Subject: [PATCH 2/3] Pin exact truncated output in workflow data source regression tests Assert accessibility_label() text to confirm the truncation floors to the char boundary and appends exactly one ellipsis, rather than only checking that a result was returned. Co-Authored-By: Warp Agent --- .../ai_context_menu/workflows/data_source_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/search/ai_context_menu/workflows/data_source_tests.rs b/app/src/search/ai_context_menu/workflows/data_source_tests.rs index e92667e6e85..7233cfdf723 100644 --- a/app/src/search/ai_context_menu/workflows/data_source_tests.rs +++ b/app/src/search/ai_context_menu/workflows/data_source_tests.rs @@ -104,6 +104,12 @@ fn run_query_does_not_panic_on_multibyte_content_straddling_truncation_boundary( let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); assert_eq!(results.len(), 1); + // The description should be floored to the char boundary before '世' (196 bytes in), + // dropping the multi-byte tail entirely, with exactly one ellipsis appended. + assert_eq!( + results[0].accessibility_label(), + format!("Workflow: multibyte - {}...", "a".repeat(196)) + ); }) } @@ -122,5 +128,9 @@ fn short_content_is_not_truncated() { let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); assert_eq!(results.len(), 1); + assert_eq!( + results[0].accessibility_label(), + "Workflow: short - short content" + ); }) } From 6d3a638d9370ec17d4d601ea4d931e12b06bc73a Mon Sep 17 00:00:00 2001 From: Oz Date: Wed, 12 Aug 2026 00:15:16 +0000 Subject: [PATCH 3/3] Remove regression test per reviewer request David is confident this fixes the issue and doesn't expect a future regression, so drop the unit test and keep the PR to the one-line safe_truncate swap. Co-Authored-By: Warp Agent --- .../ai_context_menu/workflows/data_source.rs | 4 - .../workflows/data_source_tests.rs | 136 ------------------ 2 files changed, 140 deletions(-) delete mode 100644 app/src/search/ai_context_menu/workflows/data_source_tests.rs diff --git a/app/src/search/ai_context_menu/workflows/data_source.rs b/app/src/search/ai_context_menu/workflows/data_source.rs index 210f11f8d25..5f91de2a41d 100644 --- a/app/src/search/ai_context_menu/workflows/data_source.rs +++ b/app/src/search/ai_context_menu/workflows/data_source.rs @@ -135,7 +135,3 @@ impl SyncDataSource for WorkflowDataSource { impl warpui::Entity for WorkflowDataSource { type Event = (); } - -#[cfg(test)] -#[path = "data_source_tests.rs"] -mod tests; diff --git a/app/src/search/ai_context_menu/workflows/data_source_tests.rs b/app/src/search/ai_context_menu/workflows/data_source_tests.rs deleted file mode 100644 index 7233cfdf723..00000000000 --- a/app/src/search/ai_context_menu/workflows/data_source_tests.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::sync::Arc; - -use chrono::Utc; -use cloud_object_client::MockObjectClient; -use settings::manager::SettingsManager; -use warpui::{App, SingletonEntity}; - -use crate::NetworkStatus; -use crate::auth::AuthStateProvider; -use crate::cloud_object::model::persistence::CloudModel; -use crate::cloud_object::model::view::CloudViewModel; -use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow}; -use crate::notebooks::manager::NotebookManager; -use crate::search::ai_context_menu::workflows::data_source::WorkflowDataSource; -use crate::search::data_source::Query; -use crate::search::mixer::SyncDataSource; -use crate::server::cloud_objects::update_manager::UpdateManager; -use crate::server::ids::{ServerId, SyncId}; -use crate::server::server_api::ServerApiProvider; -use crate::server::server_api::team::MockTeamClient; -use crate::server::server_api::workspace::MockWorkspaceClient; -use crate::server::sync_queue::SyncQueue; -use crate::settings::AISettings; -use crate::system::SystemStats; -use crate::workflows::workflow::Workflow; -use crate::workflows::{CloudWorkflowModel, WorkflowId}; -use crate::workspaces::team_tester::TeamTesterStatus; -use crate::workspaces::user_profiles::UserProfiles; -use crate::workspaces::user_workspaces::UserWorkspaces; - -fn mock_server_workflow(id: i64, name: &str, query: &str) -> ServerWorkflow { - ServerWorkflow::new( - SyncId::ServerId(WorkflowId::from(id).into()), - CloudWorkflowModel::new(Workflow::AgentMode { - name: name.to_owned(), - query: query.to_owned(), - description: None, - arguments: Vec::new(), - }), - ServerMetadata { - uid: ServerId::default(), - revision: Revision::now(), - metadata_last_updated_ts: Utc::now().into(), - trashed_ts: None, - folder_id: None, - is_welcome_object: false, - creator_uid: None, - last_editor_uid: None, - current_editor_uid: None, - }, - ServerPermissions { - space: Owner::mock_current_user(), - guests: Vec::new(), - anyone_link_sharing: None, - permissions_last_updated_ts: Utc::now().into(), - }, - ) -} - -fn initialize_app(app: &mut App) { - app.add_singleton_model(|_| NetworkStatus::new()); - app.add_singleton_model(|_| SystemStats::new()); - let mock_team_client = Arc::new(MockTeamClient::new()); - let mock_workspace_client = Arc::new(MockWorkspaceClient::new()); - app.add_singleton_model(|ctx| { - UserWorkspaces::mock( - mock_team_client.clone(), - mock_workspace_client.clone(), - vec![], - ctx, - ) - }); - app.add_singleton_model(TeamTesterStatus::new); - app.add_singleton_model(SyncQueue::mock); - app.add_singleton_model(CloudModel::mock); - app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx)); - app.add_singleton_model(|_| UserProfiles::new(Vec::new())); - app.add_singleton_model(CloudViewModel::new); - app.add_singleton_model(NotebookManager::mock); - app.add_singleton_model(|_| ServerApiProvider::new_for_test()); - app.add_singleton_model(|_| SettingsManager::default()); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.update(crate::settings::init_and_register_user_preferences); - app.update(AISettings::register_and_subscribe_to_events); -} - -/// Regression test for APP-5287: a workflow whose first three content lines exceed 200 bytes, -/// with a multi-byte character straddling the byte-197 truncation boundary, must not panic and -/// must produce a valid, truncated description. -#[test] -fn run_query_does_not_panic_on_multibyte_content_straddling_truncation_boundary() { - // 196 ASCII bytes followed by a 3-byte CJK character puts the character's bytes at - // indices 196-198, straddling the old hard-coded byte-197 cut point. - let content = format!("{}世界", "a".repeat(196)); - - App::test((), |mut app| async move { - initialize_app(&mut app); - - CloudModel::handle(&app).update(&mut app, |model, ctx| { - model.upsert_from_server_workflow(mock_server_workflow(1, "multibyte", &content), ctx); - }); - - let data_source = WorkflowDataSource::new(); - let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); - - assert_eq!(results.len(), 1); - // The description should be floored to the char boundary before '世' (196 bytes in), - // dropping the multi-byte tail entirely, with exactly one ellipsis appended. - assert_eq!( - results[0].accessibility_label(), - format!("Workflow: multibyte - {}...", "a".repeat(196)) - ); - }) -} - -#[test] -fn short_content_is_not_truncated() { - let content = "short content"; - - App::test((), |mut app| async move { - initialize_app(&mut app); - - CloudModel::handle(&app).update(&mut app, |model, ctx| { - model.upsert_from_server_workflow(mock_server_workflow(1, "short", content), ctx); - }); - - let data_source = WorkflowDataSource::new(); - let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap()); - - assert_eq!(results.len(), 1); - assert_eq!( - results[0].accessibility_label(), - "Workflow: short - short content" - ); - }) -}