Add item anchors to thread/items/list pagination (#48151)

## What changed

Allow `cursor` to accept an item anchor such as `{"type":"item","itemId":"item-123"}` with a non-empty `turnId`. Ascending pagination returns items after the anchor; descending pagination returns items before it. The anchor itself is excluded, and subsequent pages use the returned opaque string cursor.

Resolve anchors within the requested turn's visible history, including inherited fork history. Reject missing turn IDs and empty, unknown, or out-of-scope item IDs with invalid params (`-32602`). Preserve existing string and null cursor behavior, and update the protocol schemas, TypeScript and Python types, and API documentation.

## Testing

Add protocol round-trip and invalid-shape tests, app-server coverage for anchored pagination and errors, and thread-store coverage for growing turns, reverse pagination, page boundaries, fork visibility, and anchor scope validation.

GitOrigin-RevId: a74cea190d3ff1b44865429f8b0650b793eb688d
This commit is contained in:
Brent Traut
2026-09-25 17:57:32 +00:00
committed by copyberry
parent c7e80f873f
commit de9e78e3e7
26 changed files with 681 additions and 50 deletions
@@ -400,7 +400,7 @@ fn thread_items_list_round_trips() {
let params = ThreadItemsListParams {
thread_id: "thr_123".to_string(),
turn_id: Some("turn_456".to_string()),
cursor: Some("cursor_1".to_string()),
cursor: Some(ThreadItemsListCursor::Opaque("cursor_1".to_string())),
limit: Some(50),
sort_direction: Some(SortDirection::Asc),
};
@@ -415,6 +415,26 @@ fn thread_items_list_round_trips() {
"sortDirection": "asc",
})
);
for cursor in [
json!("cursor_1"),
json!({"type": "item", "itemId": "item-1"}),
serde_json::Value::Null,
] {
let mut value = serde_json::to_value(&params).unwrap();
value["cursor"] = cursor;
let request = serde_json::from_value::<ThreadItemsListParams>(value.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), value);
}
for invalid in [
json!({"type": "other", "itemId": "item-1"}),
json!({"type": "item"}),
json!({"type": "item", "itemId": 1}),
json!(42),
] {
let mut value = serde_json::to_value(&params).unwrap();
value["cursor"] = invalid;
assert!(serde_json::from_value::<ThreadItemsListParams>(value).is_err());
}
for (started_at_ms, completed_at_ms) in [
(Some(1_789_855_978_123), Some(1_789_855_979_456)),
(Some(1_789_855_978_123), None),
@@ -1741,9 +1741,11 @@ pub struct ThreadItemsListParams {
/// Optional turn id to filter by. When omitted, returns items across the thread.
#[ts(optional = nullable)]
pub turn_id: Option<String>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// Opaque continuation cursor or an exclusive item anchor in the requested visible turn.
/// An item anchor requires a non-empty `turnId`; ascending (the default) returns items
/// after it, and descending returns items before it. Continue with the returned string cursor.
#[ts(optional = nullable)]
pub cursor: Option<String>,
pub cursor: Option<ThreadItemsListCursor>,
/// Optional item page size.
#[ts(optional = nullable)]
pub limit: Option<u32>,
@@ -1752,6 +1754,27 @@ pub struct ThreadItemsListParams {
pub sort_direction: Option<SortDirection>,
}
/// Starting position for an item-history page.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum ThreadItemsListCursor {
Opaque(String),
Anchor(ThreadItemsListAnchor),
}
/// An exclusive item position within the requested visible turn.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadItemsListAnchor {
Item {
#[serde(rename = "itemId")]
#[ts(rename = "itemId")]
item_id: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]