From d3e584093222018fc27919403aea149ccfd3bb38 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 22 Sep 2026 10:55:07 +0000 Subject: [PATCH] Subscribe authors to message-board channels created by posting (#47259) ## Why Posting to a new channel subscribed its author to replies on that post, but not to new discussion roots in the channel. The creator could therefore miss notifications for subsequent discussions. ## What changed Subscribe the author to the channel in the same transaction that creates the channel and its first post. Document this behavior in the API and the `new_channel_name` tool parameter. ## Testing Update coverage to verify that channel creators receive notifications without explicit targeting and that posting to an existing channel preserves an explicit unsubscribe. Adjust thread-deletion test expectations for the additional persisted subscription. GitOrigin-RevId: 5553ebc9f9b51456ab48886e9871733e93370a2b --- .../tests/suite/v2/thread_delete.rs | 4 +-- codex-rs/ext/agent-message-board/src/api.rs | 1 + codex-rs/ext/agent-message-board/src/local.rs | 6 +++++ .../ext/agent-message-board/src/tools/spec.rs | 3 +++ .../agent-message-board/tests/local_board.rs | 26 +++++++++++++++---- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_delete.rs b/codex-rs/app-server/tests/suite/v2/thread_delete.rs index e3c2a354dd..9c9f96d865 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_delete.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_delete.rs @@ -391,7 +391,7 @@ async fn thread_delete_removes_persisted_board_even_with_feature_disabled() -> R /*busy_timeout*/ None, ) .await?; - assert_eq!(board_counts(&pool).await?, (1, 1, 1)); + assert_eq!(board_counts(&pool).await?, (1, 1, 2)); let _: ThreadArchiveResponse = app .request(|request_id| ClientRequest::ThreadArchive { request_id, @@ -409,7 +409,7 @@ async fn thread_delete_removes_persisted_board_even_with_feature_disabled() -> R .with_codex_home(home.path()) .build_initialized() .await?; - assert_eq!(board_counts(&pool).await?, (1, 1, 1)); + assert_eq!(board_counts(&pool).await?, (1, 1, 2)); let _: ThreadDeleteResponse = app .request(|request_id| ClientRequest::ThreadDelete { request_id, diff --git a/codex-rs/ext/agent-message-board/src/api.rs b/codex-rs/ext/agent-message-board/src/api.rs index 495fe0a25e..fee95aa4ac 100644 --- a/codex-rs/ext/agent-message-board/src/api.rs +++ b/codex-rs/ext/agent-message-board/src/api.rs @@ -51,6 +51,7 @@ pub trait AgentMessageBoard: Send + Sync { /// Uses the caller's configured clock; clock failures must not create a post. /// The request ID identifies a logical call across retries. A retry with /// different input is an error; a successful retry returns the same metadata. + /// Creating a channel while posting also subscribes its author to new roots there. fn post(&self, caller: ThreadId, request: PostRequest) -> BoxFuture<'_, Result>; fn list_threads( diff --git a/codex-rs/ext/agent-message-board/src/local.rs b/codex-rs/ext/agent-message-board/src/local.rs index ee4a069b56..6413ae1764 100644 --- a/codex-rs/ext/agent-message-board/src/local.rs +++ b/codex-rs/ext/agent-message-board/src/local.rs @@ -235,6 +235,12 @@ impl LocalAgentMessageBoard { PostDestination::NewChannel(channel) => { validate_channel(channel)?; self.insert_channel(&mut tx, channel, &author, now).await?; + self.subscribe( + &mut tx, + &SubscriptionTarget::Channel(channel.clone()), + caller, + ) + .await?; ( channel.clone(), id, diff --git a/codex-rs/ext/agent-message-board/src/tools/spec.rs b/codex-rs/ext/agent-message-board/src/tools/spec.rs index 134ec17473..c9b4410785 100644 --- a/codex-rs/ext/agent-message-board/src/tools/spec.rs +++ b/codex-rs/ext/agent-message-board/src/tools/spec.rs @@ -87,6 +87,9 @@ pub(super) fn tool(name: &str, namespace: Option<&str>, namespace_description: & let mut properties = serde_json::Map::new(); for field in fields { let schema = match *field { + "new_channel_name" => { + json!({"type":"string","description":"Create and subscribe to this channel."}) + } "subscribe" => { json!({"type":"boolean","description":"Subscribe to new roots. Default true."}) } diff --git a/codex-rs/ext/agent-message-board/tests/local_board.rs b/codex-rs/ext/agent-message-board/tests/local_board.rs index 8ed21b1e70..6c1eaa0292 100644 --- a/codex-rs/ext/agent-message-board/tests/local_board.rs +++ b/codex-rs/ext/agent-message-board/tests/local_board.rs @@ -102,11 +102,13 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() { .await .unwrap(); first - .create_channel( + .post( child, - CreateChannelRequest { - channel_name: "proofs".into(), - subscription: SubscriptionChange::Subscribe, + PostRequest { + request_id: "create-proofs".into(), + destination: PostDestination::NewChannel("proofs".into()), + text: "Share proofs here.".into(), + agents_to_notify: Vec::new(), }, ) .await @@ -115,7 +117,8 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() { request_id: "call-1".into(), destination: PostDestination::Channel("proofs".into()), text: "é🦀 proof".into(), - agents_to_notify: vec![AgentPath::root(), child_path.clone(), child_path.clone()], + // The creator receives this through its channel subscription. + agents_to_notify: vec![AgentPath::root()], }; let second = LocalAgentMessageBoard::open(&sqlite, tree, host.clone()) .await @@ -180,6 +183,19 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() { ) .await .unwrap(); + // Posting to an existing channel must not re-enable its subscription. + resumed + .post( + child, + PostRequest { + request_id: "one-off-post".into(), + destination: PostDestination::Channel("proofs".into()), + text: "One more proof, while staying unsubscribed.".into(), + agents_to_notify: Vec::new(), + }, + ) + .await + .unwrap(); // Posting still succeeds when the subscriber lookup returns an empty array. resumed .post(