feat(app-server): include turns page on thread resume (#23534)

## Summary

The client currently calls `thread/resume` to establish live updates and
immediately follows it with `thread/turns/list` to hydrate recent turns.
This lets `thread/resume` return that page directly, eliminating a round
trip and the ordering/deduplication gap between the two calls.

Experimental clients opt in with `initialTurnsPage: { limit,
sortDirection, itemsView }`. The response returns `initialTurnsPage` as
a `TurnsPage`, including cursors for paging further back in history.
Keeping the controls in a nested opt-in object provides the useful
`thread/turns/list` knobs without spreading page-specific parameters
across `thread/resume`.

## Verification

- `just fmt`
- `just write-app-server-schema --experimental`
- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server
thread_resume_initial_turns_page_matches_requested_turns_list_page
--tests`
- `cargo test -p codex-app-server
thread_resume_rejoins_running_thread_even_with_override_mismatch
--tests`
- `just fix -p codex-app-server-protocol -p codex-app-server`
This commit is contained in:
Brent Traut
2026-05-28 09:18:13 -07:00
committed by GitHub
parent 2066874415
commit 2a1158b8e2
22 changed files with 730 additions and 101 deletions
@@ -3543,6 +3543,42 @@
}
]
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
"properties": {
@@ -17150,6 +17150,42 @@
"title": "ThreadRealtimeTranscriptDoneNotification",
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/v2/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/v2/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -18488,6 +18524,32 @@
"title": "TurnSteerResponse",
"type": "object"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/v2/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -14974,6 +14974,42 @@
"title": "ThreadRealtimeTranscriptDoneNotification",
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -16312,6 +16348,32 @@
"title": "TurnSteerResponse",
"type": "object"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -983,6 +983,74 @@
"danger-full-access"
],
"type": "string"
},
"SortDirection": {
"enum": [
"asc",
"desc"
],
"type": "string"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
}
},
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -1995,6 +1995,32 @@
],
"type": "string"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -0,0 +1,19 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SortDirection } from "./SortDirection";
import type { TurnItemsView } from "./TurnItemsView";
export type ThreadResumeInitialTurnsPageParams = {
/**
* Optional turn page size.
*/
limit?: number | null,
/**
* Optional turn pagination direction; defaults to descending.
*/
sortDirection?: SortDirection | null,
/**
* How much item detail to include for each returned turn; defaults to summary.
*/
itemsView?: TurnItemsView | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Turn } from "./Turn";
export type TurnsPage = { data: Array<Turn>, nextCursor: string | null, backwardsCursor: string | null, };
@@ -398,6 +398,7 @@ export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTranspor
export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification";
export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification";
export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification";
export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams";
export type { ThreadResumeParams } from "./ThreadResumeParams";
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
export type { ThreadRollbackParams } from "./ThreadRollbackParams";
@@ -450,6 +451,7 @@ export type { TurnStartedNotification } from "./TurnStartedNotification";
export type { TurnStatus } from "./TurnStatus";
export type { TurnSteerParams } from "./TurnSteerParams";
export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { TurnsPage } from "./TurnsPage";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WebSearchAction } from "./WebSearchAction";
@@ -111,6 +111,85 @@ fn thread_turns_list_params_accepts_items_view() {
assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded));
}
#[test]
fn thread_resume_params_accept_turns_page_bootstrap() {
let params = serde_json::from_value::<ThreadResumeParams>(json!({
"threadId": "thr_123",
"initialTurnsPage": {
"limit": 25,
"sortDirection": "asc",
"itemsView": "full",
},
}))
.expect("thread resume params should deserialize");
assert_eq!(params.thread_id, "thr_123");
assert_eq!(
params.initial_turns_page,
Some(ThreadResumeInitialTurnsPageParams {
limit: Some(25),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::Full),
})
);
}
#[test]
fn thread_resume_response_round_trips_initial_turns_page() {
let response = ThreadResumeResponse {
thread: Thread {
id: "thr_123".to_string(),
session_id: "thr_123".to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
status: ThreadStatus::Idle,
path: None,
cwd: absolute_path("tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Exec,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: Vec::new(),
},
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: absolute_path("tmp"),
runtime_workspace_roots: Vec::new(),
instruction_sources: Vec::new(),
approval_policy: AskForApproval::OnFailure,
approvals_reviewer: ApprovalsReviewer::User,
sandbox: SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
initial_turns_page: Some(TurnsPage {
data: Vec::new(),
next_cursor: Some("cursor_next".to_string()),
backwards_cursor: Some("cursor_back".to_string()),
}),
};
let value = serde_json::to_value(&response).expect("serialize thread resume response");
assert_eq!(
value.get("initialTurnsPage"),
Some(&json!({
"data": [],
"nextCursor": "cursor_next",
"backwardsCursor": "cursor_back",
}))
);
let decoded = serde_json::from_value::<ThreadResumeResponse>(value)
.expect("deserialize thread resume response");
assert_eq!(decoded, response);
}
#[test]
fn thread_turns_items_list_round_trips() {
let params = ThreadTurnsItemsListParams {
@@ -3406,6 +3485,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.active_permission_profile, None);
assert_eq!(resume.active_permission_profile, None);
assert_eq!(resume.initial_turns_page, None);
assert_eq!(fork.active_permission_profile, None);
}
@@ -397,6 +397,11 @@ pub struct ThreadResumeParams {
#[experimental("thread/resume.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// When present, include a `thread/turns/list` page in the resume response
/// so clients can bootstrap recent turns without a second request.
#[experimental("thread/resume.initialTurnsPage")]
#[ts(optional = nullable)]
pub initial_turns_page: Option<ThreadResumeInitialTurnsPageParams>,
/// Deprecated and ignored by app-server. Kept only so older clients can
/// continue sending the field while rollout persistence always uses the
/// limited history policy.
@@ -435,6 +440,44 @@ pub struct ThreadResumeResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// `thread/turns/list` page returned when requested by `initialTurnsPage`.
#[experimental("thread/resume.initialTurnsPage")]
#[serde(default)]
pub initial_turns_page: Option<TurnsPage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadResumeInitialTurnsPageParams {
/// Optional turn page size.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional turn pagination direction; defaults to descending.
#[ts(optional = nullable)]
pub sort_direction: Option<SortDirection>,
/// How much item detail to include for each returned turn; defaults to summary.
#[ts(optional = nullable)]
pub items_view: Option<TurnItemsView>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnsPage {
pub data: Vec<Turn>,
pub next_cursor: Option<String>,
pub backwards_cursor: Option<String>,
}
impl From<ThreadTurnsListResponse> for TurnsPage {
fn from(response: ThreadTurnsListResponse) -> Self {
Self {
data: response.data,
next_cursor: response.next_cursor,
backwards_cursor: response.backwards_cursor,
}
}
}
#[derive(