agent: Add a regression test to ensure title updates do not loop (#52395)

Bennet Bo Fenner created

## Context

Follow up to #52388 
## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [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

Change summary

crates/agent/src/agent.rs | 64 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)

Detailed changes

crates/agent/src/agent.rs 🔗

@@ -3002,6 +3002,70 @@ mod internal_tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_rapid_title_changes_do_not_loop(cx: &mut TestAppContext) {
+        // Regression test: rapid title changes must not cause a propagation loop
+        // between Thread and AcpThread via handle_thread_title_updated.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/", json!({ "a": {} })).await;
+        let project = Project::test(fs.clone(), [], cx).await;
+        let thread_store = cx.new(|cx| ThreadStore::new(cx));
+        let agent = cx.update(|cx| {
+            NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+        });
+        let connection = Rc::new(NativeAgentConnection(agent.clone()));
+
+        let acp_thread = cx
+            .update(|cx| {
+                connection
+                    .clone()
+                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
+            })
+            .await
+            .unwrap();
+
+        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let thread = agent.read_with(cx, |agent, _| {
+            agent.sessions.get(&session_id).unwrap().thread.clone()
+        });
+
+        let title_updated_count = Rc::new(std::cell::RefCell::new(0usize));
+        cx.update(|cx| {
+            let count = title_updated_count.clone();
+            cx.subscribe(
+                &thread,
+                move |_entity: Entity<Thread>, _event: &TitleUpdated, _cx: &mut App| {
+                    let new_count = {
+                        let mut count = count.borrow_mut();
+                        *count += 1;
+                        *count
+                    };
+                    assert!(
+                        new_count <= 2,
+                        "TitleUpdated fired {new_count} times; \
+                         title updates are looping"
+                    );
+                },
+            )
+            .detach();
+        });
+
+        thread.update(cx, |thread, cx| thread.set_title("first".into(), cx));
+        thread.update(cx, |thread, cx| thread.set_title("second".into(), cx));
+
+        cx.run_until_parked();
+
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.title(), Some("second".into()));
+        });
+        acp_thread.read_with(cx, |acp_thread, _| {
+            assert_eq!(acp_thread.title(), Some("second".into()));
+        });
+
+        assert_eq!(*title_updated_count.borrow(), 2);
+    }
+
     fn thread_entries(
         thread_store: &Entity<ThreadStore>,
         cx: &mut TestAppContext,