add turn items view to app-server turns (#21063)

## Why

`Turn.items` currently overloads an empty array to mean either that no
items exist or that the server intentionally did not load them for this
response. That ambiguity blocks future lazy-loading work where clients
need to distinguish unloaded, summary, and fully hydrated turn payloads.

## What changed

- add a new `TurnItemsView` enum with `notLoaded`, `summary`, and `full`
variants
- add required `itemsView` metadata to app-server `Turn` payloads
- mark reconstructed persisted history as `full` and live shell-style
turn payloads as `notLoaded`
- keep current `thread/turns/list` behavior unchanged and document that
it still returns `full` turns today
- regenerate the JSON and TypeScript protocol fixtures

## Verification

- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server thread_read_can_include_turns`
- `cargo test -p codex-app-server
thread_turns_list_can_page_backward_and_forward`
- `cargo test -p codex-app-server
thread_resume_rejects_history_when_thread_is_running`
- `just fix -p codex-app-server-protocol`
- `just fix -p codex-app-server`
- `just fmt`
This commit is contained in:
rhan-oai
2026-05-05 12:17:16 -07:00
committed by GitHub
Unverified
parent b6d4c4ea6b
commit 9e0c191c13
48 changed files with 821 additions and 27 deletions
@@ -17,6 +17,7 @@ use crate::protocol::v2::ThreadItem;
use crate::protocol::v2::Turn;
use crate::protocol::v2::TurnError as V2TurnError;
use crate::protocol::v2::TurnError;
use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
use crate::protocol::v2::WebSearchAction;
@@ -1166,6 +1167,7 @@ impl From<PendingTurn> for Turn {
Self {
id: value.id,
items: value.items,
items_view: TurnItemsView::Full,
error: value.error,
status: value.status,
started_at: value.started_at,
@@ -1180,6 +1182,7 @@ impl From<&PendingTurn> for Turn {
Self {
id: value.id.clone(),
items: value.items.clone(),
items_view: TurnItemsView::Full,
error: value.error.clone(),
status: value.status.clone(),
started_at: value.started_at,
@@ -1453,6 +1456,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: vec![
ThreadItem::UserMessage {
id: "item-1".into(),
@@ -2723,6 +2727,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: Vec::new(),
}]
);
@@ -2982,6 +2987,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: vec![ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
@@ -5434,10 +5434,11 @@ impl From<CoreTokenUsage> for TokenUsageBreakdown {
#[ts(export_to = "v2/")]
pub struct Turn {
pub id: String,
/// Only populated on a `thread/resume` or `thread/fork` response.
/// For all other responses and notifications returning a Turn,
/// the items field will be an empty list.
/// Thread items currently included in this turn payload.
pub items: Vec<ThreadItem>,
/// Describes how much of `items` has been loaded for this turn.
#[serde(default)]
pub items_view: TurnItemsView,
pub status: TurnStatus,
/// Only populated when the Turn's status is failed.
pub error: Option<TurnError>,
@@ -5452,6 +5453,19 @@ pub struct Turn {
pub duration_ms: Option<i64>,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum TurnItemsView {
/// `items` was not loaded for this turn. The field is intentionally empty.
NotLoaded,
/// `items` contains only a display summary for this turn.
Summary,
/// `items` contains every ThreadItem available from persisted app-server history for this turn.
#[default]
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8441,6 +8455,22 @@ mod tests {
}
}
#[test]
fn turn_defaults_legacy_missing_items_view_to_full() {
let turn: Turn = serde_json::from_value(json!({
"id": "turn_123",
"items": [],
"status": "completed",
"error": null,
"startedAt": null,
"completedAt": null,
"durationMs": null,
}))
.expect("legacy turn should deserialize");
assert_eq!(turn.items_view, TurnItemsView::Full);
}
#[test]
fn thread_list_params_accepts_single_cwd() {
let params = serde_json::from_value::<ThreadListParams>(json!({