mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user