Add app-server APIs for stored thread attachments (#44564)

## What changed

- Add `thread/attachment/add`, `thread/attachment/list`, and `thread/attachment/remove` to manage durable resource references without loading the owning thread or changing conversation history.
- Identify attachments by thread, `attachmentType`, and `identityKey`. Repeated adds return the existing attachment; repeated removals succeed without emitting another update. Listing supports cursor pagination.
- Broadcast `thread/attachment/updated` after creation or deletion, after responding to the requester. Serialize attachment mutations with thread lifecycle operations and reject stores that do not support attachments.
- Update protocol schemas, TypeScript and Python bindings, documentation, and TUI notification handling so attachment updates do not add untracked threads to the agent picker.

## Testing

Add coverage for unloaded threads, pagination, idempotent addition and removal, reattachment, invalid inputs, unsupported stores, response-before-notification ordering across clients, and TUI notification routing.

GitOrigin-RevId: 0d29cf903afd4e931a70a9cf96300475213b34aa
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-09-10 15:52:09 +00:00
1 parent 94697375cb
commit 3319d9b296
47 files changed
+2341 -6

No files matched your search

@@ -670,6 +670,21 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadMetadataUpdateResponse,
},
ThreadAttachmentAdd => "thread/attachment/add" {
params: v2::ThreadAttachmentAddParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadAttachmentAddResponse,
},
ThreadAttachmentList => "thread/attachment/list" {
params: v2::ThreadAttachmentListParams,
serialization: None,
response: v2::ThreadAttachmentListResponse,
},
ThreadAttachmentRemove => "thread/attachment/remove" {
params: v2::ThreadAttachmentRemoveParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadAttachmentRemoveResponse,
},
ThreadSectionMove => "thread/section/move" {
params: v2::ThreadSectionMoveParams,
serialization: thread_id(params.thread_id),
@@ -1901,6 +1916,7 @@ server_notification_definitions! {
ThreadReverted => "thread/reverted" (v2::ThreadRevertedNotification),
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
ThreadAttachmentUpdated => "thread/attachment/updated" (v2::ThreadAttachmentUpdatedNotification),
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
#[experimental("thread/queue/changed")]
@@ -31,6 +31,7 @@ mod realtime;
mod remote_control;
mod review;
mod thread;
mod thread_attachment;
mod thread_data;
mod thread_usage;
mod turn;
@@ -69,6 +70,7 @@ pub use remote_control::*;
pub use review::*;
pub use shared::*;
pub use thread::*;
pub use thread_attachment::*;
pub use thread_data::*;
pub use thread_usage::*;
pub use turn::*;
@@ -0,0 +1,107 @@
//! Wire contracts for managing the current set of attachments on a thread.
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
/// An independently persisted attachment associated with a thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachment {
pub id: String,
pub attachment_type: String,
pub identity_key: String,
pub payload: JsonValue,
#[ts(type = "number")]
pub created_at: i64,
}
/// Parameters for creating or locating an attachment on its owning thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentAddParams {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
pub payload: JsonValue,
}
/// Result of attempting to associate an attachment with a thread.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadAttachmentAddOutcome {
Created,
Existing,
}
/// The created or existing attachment.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentAddResponse {
pub outcome: ThreadAttachmentAddOutcome,
pub attachment: ThreadAttachment,
}
/// Parameters for listing attachments from one thread.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentListParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
/// One page of attachments associated with the requested thread.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentListResponse {
pub data: Vec<ThreadAttachment>,
pub next_cursor: Option<String>,
}
/// Parameters for deleting an attachment by its stable thread-local identity.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentRemoveParams {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
}
/// Successful deletion does not return additional attachment data.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentRemoveResponse {}
/// The persisted attachment change represented by a notification.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadAttachmentOperation {
Created,
Deleted,
}
/// Notification published after a thread attachment is created or deleted.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentUpdatedNotification {
pub thread_id: String,
pub attachment_type: String,
pub identity_key: String,
pub attachment_id: String,
pub operation: ThreadAttachmentOperation,
}