Add persisted thread pinning to the app server (#34840)

## What changed

- Add `isPinned` to thread responses and allow `thread/metadata/update` to pin or unpin stored threads.
- Add an `isPinned` filter to `thread/list`, including cursor-based pagination and combinations with relationship filters.
- Persist pin state in SQLite without modifying rollout files, default existing threads to unpinned, and preserve pins through reconciliation and archive transitions.

## Testing

- Cover protocol serialization, database migration and indexing, pin updates, filtered pagination, rollout reconciliation, and archive/unarchive behavior.

GitOrigin-RevId: c2eacabd6dae465e2ae6ce8a169e00740960cdb7
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-07-22 22:54:28 +00:00
1 parent d7e8f4c3dc
commit 400ee190c3
71 files changed
+1058 -60

No files matched your search

@@ -185,6 +185,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
is_pinned: true,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
@@ -225,6 +226,17 @@ fn thread_resume_response_round_trips_initial_turns_page() {
};
let value = serde_json::to_value(&response).expect("serialize thread resume response");
assert_eq!(value["thread"]["isPinned"], json!(true));
let mut legacy_thread = value["thread"].clone();
legacy_thread
.as_object_mut()
.expect("serialized thread should be an object")
.remove("isPinned");
let legacy_thread =
serde_json::from_value::<Thread>(legacy_thread).expect("deserialize legacy thread");
assert!(!legacy_thread.is_pinned);
assert_eq!(
value.get("initialTurnsPage"),
Some(&json!({
@@ -356,6 +368,42 @@ fn thread_list_params_accepts_state_db_only_flag() {
assert!(params.use_state_db_only);
}
#[test]
fn thread_list_params_accepts_pinned_filter() {
for is_pinned in [true, false] {
let params = serde_json::from_value::<ThreadListParams>(json!({
"isPinned": is_pinned,
}))
.expect("pinned filter should deserialize");
assert_eq!(params.is_pinned, Some(is_pinned));
}
let params = serde_json::from_value::<ThreadListParams>(json!({}))
.expect("omitted pinned filter should deserialize");
assert_eq!(params.is_pinned, None);
}
#[test]
fn thread_metadata_update_params_accepts_pinned_patch() {
for is_pinned in [true, false] {
let params = serde_json::from_value::<ThreadMetadataUpdateParams>(json!({
"threadId": "thr_123",
"isPinned": is_pinned,
}))
.expect("pinned metadata patch should deserialize");
assert_eq!(params.is_pinned, Some(is_pinned));
assert_eq!(params.git_info, None);
}
let params = serde_json::from_value::<ThreadMetadataUpdateParams>(json!({
"threadId": "thr_123",
}))
.expect("omitted pinned metadata patch should deserialize");
assert_eq!(params.is_pinned, None);
}
#[test]
fn collab_agent_state_maps_interrupted_status() {
assert_eq!(