From 800706d7a8696572fd713ab2c0ff42564639d27b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 21 May 2026 14:25:34 +0200 Subject: [PATCH] agent: Add experimental update title tool (#57395) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- assets/settings/default.json | 2 + crates/agent/src/templates.rs | 3 +- .../templates/experimental_system_prompt.hbs | 11 ++ crates/agent/src/templates/system_prompt.hbs | 11 ++ crates/agent/src/tests/mod.rs | 150 +++++++++++++++ crates/agent/src/thread.rs | 171 +++++++++++++++++- crates/agent/src/tools.rs | 3 + crates/agent/src/tools/update_title_tool.rs | 140 ++++++++++++++ crates/agent_ui/src/agent_panel.rs | 6 +- crates/feature_flags/src/flags.rs | 12 ++ .../src/pages/tool_permissions_setup.rs | 3 + 11 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 crates/agent/src/tools/update_title_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index b22e8589183..ca782b5c8e8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1133,6 +1133,7 @@ "spawn_agent": true, "terminal": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, @@ -1153,6 +1154,7 @@ "skill": true, "spawn_agent": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index b369b07b81f..51f27eaeddf 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -83,7 +83,7 @@ mod tests { let project = prompt_store::ProjectContext::default(); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into(), "update_plan".into()], + available_tools: vec!["echo".into(), "update_plan".into(), "update_title".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -94,6 +94,7 @@ mod tests { assert!(rendered.contains("Today's Date: 2026-01-01")); assert!(rendered.contains("## Fixing Diagnostics")); assert!(rendered.contains("## Planning")); + assert!(rendered.contains("## Session Title")); assert!(rendered.contains("test-model")); } diff --git a/crates/agent/src/templates/experimental_system_prompt.hbs b/crates/agent/src/templates/experimental_system_prompt.hbs index 2611efc671c..824ee679a2a 100644 --- a/crates/agent/src/templates/experimental_system_prompt.hbs +++ b/crates/agent/src/templates/experimental_system_prompt.hbs @@ -52,6 +52,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c7128d9cb6b..54962540757 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -74,6 +74,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 9592d8f7928..eab1c031f0b 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3807,6 +3807,155 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_update_title_tool_sets_thread_title(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let input = json!({ + "title": "Session title tool" + }); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "title_1".into(), + name: UpdateTitleTool::NAME.into(), + raw_input: input.to_string(), + input, + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!( + tool_call, + acp::ToolCall::new("title_1", "Update title: Session title tool") + .kind(acp::ToolKind::Think) + .raw_input(json!({ + "title": "Session title tool" + })) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "update_title".into() + )])) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Session title updated") + ) + ); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Session title tool".into())); + }); + assert_eq!(summary_model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_update_title_availability_suppresses_summary_title_generation( + cx: &mut TestAppContext, +) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), None); + }); + assert_eq!(summary_model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_update_title_flag_without_available_tool_falls_back_to_summary_title_generation( + cx: &mut TestAppContext, +) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + assert_eq!(summary_model.pending_completions().len(), 1); + + summary_model.send_last_completion_stream_text_chunk("Fallback title"); + summary_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Fallback title".into())); + }); +} + #[gpui::test] async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; @@ -4320,6 +4469,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { StreamingFailingEchoTool::NAME: true, TerminalTool::NAME: true, UpdatePlanTool::NAME: true, + UpdateTitleTool::NAME: true, } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 55d7b62b99b..147266b678a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,12 +4,14 @@ use crate::{ FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, UserAgentsMd, WebSearchTool, WriteFileTool, decide_permission_from_settings, + UpdatePlanTool, UpdateTitleTool, UserAgentsMd, WebSearchTool, WriteFileTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use feature_flags::{ FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, + UpdateTitleToolFeatureFlag, }; use agent_client_protocol::schema as acp; @@ -1247,10 +1249,11 @@ impl Thread { // but still display the saved result if available. // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. + let title = Self::title_for_replayed_tool_use(tool_use); stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( - acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + acp::ToolCall::new(tool_use.id.to_string(), title.clone()) .status(status) .raw_input(tool_use.input.clone()), ))) @@ -1258,6 +1261,9 @@ impl Thread { let mut fields = acp::ToolCallUpdateFields::new() .status(status) .raw_output(output); + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + fields = fields.title(title); + } if let Some(content) = replay_content { fields = fields.content(content); } @@ -1305,6 +1311,16 @@ impl Thread { ); } + fn title_for_replayed_tool_use(tool_use: &LanguageModelToolUse) -> String { + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + let input = serde_json::from_value(tool_use.input.clone()) + .map_err(|_| serde_json::Value::String(tool_use.raw_input.clone())); + UpdateTitleTool::title_for_input(input).to_string() + } else { + tool_use.name.to_string() + } + } + fn tool_result_content_for_replay( tool_result: &LanguageModelToolResult, ) -> Option> { @@ -1670,6 +1686,9 @@ impl Thread { if cx.has_flag::() { self.add_tool(UpdatePlanTool); } + if cx.has_flag::() { + self.add_tool(UpdateTitleTool::new(cx.weak_entity())); + } self.add_tool(ReadFileTool::new( self.project.clone(), self.action_log.clone(), @@ -2183,7 +2202,7 @@ impl Thread { this.update(cx, |this, cx| { this.flush_pending_message(cx); - if this.title.is_none() && this.pending_title_generation.is_none() { + if this.title.is_none() { this.generate_title(cx); } })?; @@ -2712,6 +2731,20 @@ impl Thread { self.title_generation_failed } + pub fn can_generate_title(&self, cx: &App) -> bool { + self.pending_title_generation.is_none() + && self.summarization_model.is_some() + && !self.update_title_tool_available(cx) + } + + fn update_title_tool_available(&self, cx: &App) -> bool { + if let Some(running_turn) = self.running_turn.as_ref() { + running_turn.tools.contains_key(UpdateTitleTool::NAME) + } else { + self.enabled_tools(cx).contains_key(UpdateTitleTool::NAME) + } + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -2773,6 +2806,10 @@ impl Thread { } pub fn generate_title(&mut self, cx: &mut Context) { + if !self.can_generate_title(cx) { + return; + } + self.title_generation_failed = false; let Some(model) = self.summarization_model.clone() else { return; @@ -4626,6 +4663,134 @@ mod tests { assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); } + #[gpui::test] + async fn test_update_title_tool_replay_does_not_reenter_thread(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + #[gpui::test] + async fn test_update_title_tool_replay_title_when_tool_not_registered(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + fn push_completed_update_title_tool_call( + thread: &mut Thread, + tool_use_id: LanguageModelToolUseId, + ) { + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: UpdateTitleTool::NAME.into(), + raw_input: json!({ "title": "Replayed title" }).to_string(), + input: json!({ "title": "Replayed title" }), + is_input_complete: true, + thought_signature: None, + }; + + let mut tool_results = IndexMap::default(); + tool_results.insert( + tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id, + tool_name: UpdateTitleTool::NAME.into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Text( + "Session title updated".into(), + )], + output: Some(json!("Session title updated")), + }, + ); + + thread.messages.push(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::ToolUse(tool_use)], + tool_results, + reasoning_details: None, + })); + } + #[gpui::test] async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { let (parent, _event_stream) = setup_thread_for_test(cx).await; diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 5440fe149f8..187ce7f6578 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -24,6 +24,7 @@ mod symbol_locator; mod terminal_tool; mod tool_permissions; mod update_plan_tool; +mod update_title_tool; mod web_search_tool; mod write_file_tool; @@ -80,6 +81,7 @@ pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; +pub use update_title_tool::*; pub use web_search_tool::*; pub use write_file_tool::*; @@ -172,6 +174,7 @@ tools! { SpawnAgentTool, TerminalTool, UpdatePlanTool, + UpdateTitleTool, WebSearchTool, WriteFileTool, } diff --git a/crates/agent/src/tools/update_title_tool.rs b/crates/agent/src/tools/update_title_tool.rs new file mode 100644 index 00000000000..b86b82f9ac0 --- /dev/null +++ b/crates/agent/src/tools/update_title_tool.rs @@ -0,0 +1,140 @@ +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema as acp; +use gpui::{App, SharedString, Task, WeakEntity}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +const MAX_TITLE_LEN: usize = 200; + +/// Updates the current session title. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct UpdateTitleToolInput { + /// A concise, human-readable title for the current session. + pub title: String, +} + +pub struct UpdateTitleTool { + thread: WeakEntity, +} + +impl UpdateTitleTool { + pub fn new(thread: WeakEntity) -> Self { + Self { thread } + } + + pub(crate) fn title_for_input( + input: Result, + ) -> SharedString { + let Ok(input) = input else { + return "Update title".into(); + }; + let Ok(title) = normalize_title(&input.title) else { + return "Update title".into(); + }; + format!("Update title: {title}").into() + } +} + +impl AgentTool for UpdateTitleTool { + type Input = UpdateTitleToolInput; + type Output = String; + + const NAME: &'static str = "update_title"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Think + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + Self::title_for_input(input) + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let thread = self.thread.clone(); + cx.spawn(async move |cx| { + let input = input.recv().await.map_err(|error| error.to_string())?; + let title = normalize_title(&input.title)?; + + thread + .update(cx, |thread, cx| { + thread.set_title(title.into(), cx); + }) + .map_err(|error| error.to_string())?; + + Ok("Session title updated".to_string()) + }) + } + + fn replay( + &self, + input: Self::Input, + _output: Self::Output, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> anyhow::Result<()> { + let title = self.initial_title(Ok(input), cx).to_string(); + event_stream.update_fields(acp::ToolCallUpdateFields::new().title(title)); + Ok(()) + } +} + +fn normalize_title(title: &str) -> Result { + let title = title.lines().next().unwrap_or("").trim(); + if title.is_empty() { + return Err("Title cannot be empty".to_string()); + } + Ok(util::truncate_and_trailoff(title, MAX_TITLE_LEN)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[test] + fn test_normalize_title() { + assert_eq!( + normalize_title(" Title from model\nignored").unwrap(), + "Title from model" + ); + assert!(normalize_title(" \nignored").is_err()); + } + + #[gpui::test] + async fn test_initial_title(cx: &mut TestAppContext) { + let tool = UpdateTitleTool::new(WeakEntity::new_invalid()); + + let title = cx.update(|cx| { + tool.initial_title( + Ok(UpdateTitleToolInput { + title: "Investigate title updates".to_string(), + }), + cx, + ) + }); + assert_eq!( + title, + SharedString::from("Update title: Investigate title updates") + ); + + let title = cx.update(|cx| { + tool.initial_title( + Ok(UpdateTitleToolInput { + title: " ".to_string(), + }), + cx, + ) + }); + assert_eq!(title, SharedString::from("Update title")); + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f868ff9360e..c99109a7d24 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4716,7 +4716,7 @@ impl AgentPanel { conversation_view.update(cx, |conversation_view, cx| { if let Some(thread) = conversation_view.as_native_thread(cx) { thread.update(cx, |thread, cx| { - if !thread.is_generating_title() { + if thread.can_generate_title(cx) { thread.generate_title(cx); cx.notify(); } @@ -4752,7 +4752,9 @@ impl AgentPanel { conversation_view.as_ref().is_some_and(|conversation_view| { let conversation_view = conversation_view.read(cx); conversation_view.has_user_submitted_prompt(cx) - && conversation_view.as_native_thread(cx).is_some() + && conversation_view + .as_native_thread(cx) + .is_some_and(|thread| thread.read(cx).can_generate_title(cx)) }); let has_auth_methods = match &self.base_view { diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 9f3c54394b9..1c24ac71f7d 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -59,6 +59,18 @@ impl FeatureFlag for UpdatePlanToolFeatureFlag { } register_feature_flag!(UpdatePlanToolFeatureFlag); +pub struct UpdateTitleToolFeatureFlag; + +impl FeatureFlag for UpdateTitleToolFeatureFlag { + const NAME: &'static str = "update-title-tool"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(UpdateTitleToolFeatureFlag); + pub struct LspToolFeatureFlag; impl FeatureFlag for LspToolFeatureFlag { diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 0484181fb61..3122e63d2b6 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1423,6 +1423,9 @@ mod tests { // update_plan updates UI-visible planning state but does not use // tool permission rules. "update_plan", + // update_title updates UI-visible session metadata but + // does not use tool permission rules. + "update_title", ]; let tool_info_ids: Vec<&str> = TOOLS.iter().map(|t| t.id).collect();