Deprecate full-history hydration for paginated threads (#40676)

## Why

Paginated thread history should be loaded incrementally through
`thread/turns/list` and `thread/items/list` instead of being fully reconstructed
in `thread.turns`.

## What changed

- Emit `deprecationNotice` when `thread/read`, `thread/resume`, or `thread/fork`
  requests full history for a paginated thread.
- Direct clients to omit `includeTurns` for reads or use `excludeTurns: true` for
  resumes and forks, then page history through the list APIs.
- Document the deprecation in the protocol schemas and app-server README.

## Testing

- Cover notices for paginated reads, cold and loaded resumes, and forks.
- Verify metadata-only requests, legacy threads, and rejected forks do not emit
  the notice.

GitOrigin-RevId: 0d387b46d73608f90b7960120a54af4ef332e510
This commit is contained in:
Owen Lin authored and copyberry committed 2026-08-25 19:06:42 +00:00
1 parent 5cb7a35de9
commit d132b69219
19 files changed
+261 -29

No files matched your search

@@ -14,6 +14,7 @@ use codex_app_server_protocol::ActivePermissionProfile;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
@@ -1494,6 +1495,76 @@ async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_fork_warns_for_paginated_full_history_hydration() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let conversation_id = create_fake_paginated_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let _: DeprecationNoticeNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("deprecationNotice"),
)
.await??;
let _: ThreadForkResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
mcp.clear_message_buffer();
let metadata_fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
let _: ThreadForkResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(metadata_fork_id)).await??;
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
mcp.clear_message_buffer();
let invalid_fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
last_turn_id: Some("turn-1".to_string()),
before_turn_id: Some("turn-1".to_string()),
..Default::default()
})
.await?;
let _: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(invalid_fork_id)),
)
.await??;
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
Ok(())
}
#[tokio::test]
async fn thread_fork_freezes_active_paginated_turn_as_interrupted() -> Result<()> {
assert_thread_fork_freezes_active_paginated_turn_as_interrupted(MultiAgentVersion::V1).await
@@ -11,6 +11,7 @@ use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
@@ -209,6 +210,10 @@ async fn thread_read_can_include_turns() -> Result<()> {
other => panic!("expected user message item, got {other:?}"),
}
assert_eq!(thread.status, ThreadStatus::NotLoaded);
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
Ok(())
}
@@ -244,6 +249,31 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> {
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
assert!(thread.turns.is_empty());
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
let full_read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: true,
})
.await?;
let notice: DeprecationNoticeNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("deprecationNotice"),
)
.await??;
assert_eq!(
notice,
DeprecationNoticeNotification {
summary: "Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.".to_string(),
details: None,
}
);
let _: ThreadReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(full_read_id)).await??;
let list_id = mcp
.send_thread_list_request(ThreadListParams {
@@ -24,6 +24,7 @@ use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemStartedNotification;
@@ -2385,6 +2386,80 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_resume_warns_for_paginated_full_history_hydration() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
mock_responses_config(&server.uri()).write(codex_home.path())?;
let conversation_id = create_fake_paginated_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let cold_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let notice: DeprecationNoticeNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("deprecationNotice"),
)
.await??;
assert_eq!(
notice,
DeprecationNoticeNotification {
summary: "Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.".to_string(),
details: None,
}
);
let _: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(cold_resume_id)).await??;
mcp.clear_message_buffer();
let loaded_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let _: DeprecationNoticeNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("deprecationNotice"),
)
.await??;
let _: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(loaded_resume_id)).await??;
mcp.clear_message_buffer();
let metadata_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
exclude_turns: true,
..Default::default()
})
.await?;
let _: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(metadata_resume_id)).await??;
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
Ok(())
}
#[tokio::test]
async fn thread_resume_rejects_archived_session_by_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;