Add persisted manual ordering for thread sections (#36007)

## What changed

- Add `thread/section/move` to atomically move a thread into, within, or out of a section. Threads can be inserted before an existing member or appended, and moves within a section preserve `sectionEnteredAt`.
- Add `section_position` sorting to `thread/list`, with ascending order as its default, and expose `sectionEnteredAt` in thread responses.
- Persist section positions and entry times in SQLite, including migration of existing section members into recency order. Section membership is no longer updated through `thread/metadata/update`.

## Testing

- Cover section moves, reordering, pagination, persistence across restarts and rollout reconciliation, concurrent updates, and rank renumbering.

GitOrigin-RevId: aec6d7ddedca5277029b5caf5c074975397e956c
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-07-29 18:37:22 +00:00
1 parent c41a38dd10
commit ad6fc66b6d
75 files changed
+2879 -543

No files matched your search

@@ -25,7 +25,10 @@ pub enum Account {
#[serde(rename = "chatgpt", rename_all = "camelCase")]
#[ts(rename = "chatgpt", rename_all = "camelCase")]
Chatgpt {
#[schemars(required, schema_with = "nullable_string_schema")]
#[schemars(
required,
schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
)]
email: Option<String>,
plan_type: PlanType,
},
@@ -38,12 +41,6 @@ pub enum Account {
},
}
fn nullable_string_schema(
generator: &mut schemars::r#gen::SchemaGenerator,
) -> schemars::schema::Schema {
generator.subschema_for::<Option<String>>()
}
impl From<ProviderAccount> for Account {
fn from(account: ProviderAccount) -> Self {
match account {
@@ -240,6 +240,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(),
name: "Pinned".to_string(),
}),
section_entered_at: Some(1),
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
@@ -287,15 +288,18 @@ fn thread_resume_response_round_trips_initial_turns_page() {
"name": "Pinned",
})
);
assert_eq!(value["thread"]["sectionEnteredAt"], json!(1));
let mut legacy_thread = value["thread"].clone();
legacy_thread
let legacy_thread_fields = legacy_thread
.as_object_mut()
.expect("serialized thread should be an object")
.remove("section");
.expect("serialized thread should be an object");
legacy_thread_fields.remove("section");
legacy_thread_fields.remove("sectionEnteredAt");
let legacy_thread =
serde_json::from_value::<Thread>(legacy_thread).expect("deserialize legacy thread");
assert_eq!(legacy_thread.section, None);
assert_eq!(legacy_thread.section_entered_at, None);
assert_eq!(
value.get("initialTurnsPage"),
@@ -474,39 +478,6 @@ fn thread_list_params_accepts_section_id_filter() {
);
}
#[test]
fn thread_metadata_update_params_distinguish_section_id_set_clear_and_omission() {
for section_id in [
"01984de2-8f74-7c91-a3b2-5c5e937cf318",
"01984de2-8f74-7c91-a3b2-5c5e937cf319",
] {
let params = serde_json::from_value::<ThreadMetadataUpdateParams>(json!({
"threadId": "thr_123",
"sectionId": section_id,
}))
.expect("section ID metadata patch should deserialize");
assert_eq!(
params.section_id.as_ref().map(|section| section.as_deref()),
Some(Some(section_id))
);
assert_eq!(params.git_info, None);
}
let params = serde_json::from_value::<ThreadMetadataUpdateParams>(json!({
"threadId": "thr_123",
"sectionId": null,
}))
.expect("cleared section ID metadata patch should deserialize");
assert_eq!(params.section_id, Some(None));
let params = serde_json::from_value::<ThreadMetadataUpdateParams>(json!({
"threadId": "thr_123",
}))
.expect("omitted section ID metadata patch should deserialize");
assert_eq!(params.section_id, None);
}
#[test]
fn thread_section_list_params_and_response_round_trip() {
let params = serde_json::from_value::<ThreadSectionListParams>(json!({
@@ -866,15 +866,6 @@ pub struct ThreadMetadataUpdateParams {
/// provide a string to replace the stored value.
#[ts(optional = nullable)]
pub git_info: Option<ThreadMetadataGitInfoUpdateParams>,
/// Omit to leave the section unchanged, set to `null` to clear it, or provide a section ID.
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option"
)]
#[ts(optional = nullable, type = "string | null")]
pub section_id: Option<Option<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -920,6 +911,31 @@ pub struct ThreadMetadataUpdateResponse {
pub thread: Thread,
}
/// Parameters for moving a thread within a server-owned section ordering.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSectionMoveParams {
/// Thread to move into, within, or out of a section.
pub thread_id: String,
/// Destination section, or `null` to remove the thread from its section.
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(
required,
schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
)]
#[ts(type = "string | null")]
pub section_id: Option<String>,
/// Existing thread to insert before; omission or null appends to the section.
#[ts(optional = nullable)]
pub before_thread_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSectionMoveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
@@ -1196,7 +1212,7 @@ pub struct ThreadSearchParams {
pub limit: Option<u32>,
/// Optional sort key; defaults to created_at.
#[ts(optional = nullable)]
pub sort_key: Option<ThreadSortKey>,
pub sort_key: Option<ThreadSearchSortKey>,
/// Optional sort direction; defaults to descending (newest first).
#[ts(optional = nullable)]
pub sort_direction: Option<SortDirection>,
@@ -1244,6 +1260,16 @@ pub enum ThreadSortKey {
CreatedAt,
UpdatedAt,
RecencyAt,
SectionPosition,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum ThreadSearchSortKey {
CreatedAt,
UpdatedAt,
RecencyAt,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
@@ -197,6 +197,10 @@ pub struct Thread {
/// The independently persisted section selected for this thread, if any.
#[serde(default)]
pub section: Option<ThreadSection>,
/// Unix timestamp in seconds when the thread entered its current section.
#[serde(default)]
#[ts(type = "number | null")]
pub section_entered_at: Option<i64>,
/// Persisted thread history contract selected when this thread was created.
#[experimental("thread.historyMode")]
#[serde(default)]