Add experimental thread/sample requests to the app server (#55339)

## What changed

Add `thread/sample` to collect one model response on an ephemeral fork that inherits a loaded thread's context and configuration. Return the source turn ID, response ID, assistant text, and available token usage without modifying the source conversation.

Require an idle, persistent root thread and an `expectedTurnId` matching its latest completed turn. Reject results if the source history changes during sampling. Keep the parent responsive while sampling, hide sample forks from `thread/loaded/list`, and reclaim them after completion or timeout.

Intercept responses before tool execution or Stop hooks can run, returning null text for tool calls or responses without text. Apply optional reasoning-effort updates only when the provider and model support cache-preserving updates.

## Testing

Add integration coverage for inherited context and reasoning effort, source preservation, tool and Stop hook suppression, invalid requests, stream errors, parent responsiveness, and timeout cleanup.

GitOrigin-RevId: 08f4867e53ec7f6c8f4428d5542eeea4e6dadd0b
This commit is contained in:
liame-oai
2026-10-19 18:25:07 +00:00
committed by copyberry
parent 1569abc5dc
commit 8f8e7a6e09
15 changed files with 1673 additions and 8 deletions
@@ -11,7 +11,11 @@ use super::thread_fork_goal::inherit_thread_goal_snapshot;
use super::thread_input::can_accept_direct_input;
use super::thread_input::ensure_direct_input_allowed;
use super::*;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::method_not_found;
use crate::thread_sample::SampleTask;
use crate::thread_sample::SampleThread;
use crate::thread_sample::generate_sample;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ThreadHistoryMode as ApiThreadHistoryMode;
use codex_app_server_protocol::ThreadItemsListAnchor;
@@ -19,6 +23,8 @@ use codex_app_server_protocol::ThreadItemsListCursor;
use codex_app_server_protocol::ThreadRevertParams;
use codex_app_server_protocol::ThreadRevertResponse;
use codex_app_server_protocol::ThreadRevertedNotification;
use codex_app_server_protocol::ThreadSampleParams;
use codex_app_server_protocol::ThreadSampleResponse;
use codex_app_server_protocol::ThreadSection;
use codex_app_server_protocol::ThreadSectionAppearance;
use codex_app_server_protocol::ThreadSectionMoveParams;
@@ -463,6 +469,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) state_db: Option<StateDbHandle>,
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) sample_responses: Arc<crate::thread_sample::SampleResponseRegistry>,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
@@ -505,6 +512,7 @@ impl ThreadRequestProcessor {
skills_watcher: Arc<SkillsWatcher>,
turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
initial_config_warnings: Vec<ConfigWarningNotification>,
sample_responses: Arc<crate::thread_sample::SampleResponseRegistry>,
) -> Self {
Self {
auth_manager,
@@ -522,6 +530,7 @@ impl ThreadRequestProcessor {
state_db,
log_db,
background_tasks: TaskTracker::new(),
sample_responses,
skills_watcher,
turn_cost_worker,
initial_config_warnings: Arc::new(initial_config_warnings),
@@ -603,6 +612,38 @@ impl ThreadRequestProcessor {
.map(|()| None)
}
pub(crate) async fn thread_sample(
&self,
params: ThreadSampleParams,
rpc_gate: Arc<ConnectionRpcGate>,
context: RequestContext,
) -> Result<ThreadSampleResponse, JSONRPCErrorError> {
let id = ThreadId::from_string(&params.thread_id)
.map_err(|error| invalid_request(error.to_string()))?;
let parent = self
.thread_manager
.get_thread(id)
.await
.map_err(|_| invalid_request("thread/sample requires a loaded source"))?;
self.config_manager
.check_thread_model_provider(parent.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
generate_sample(
SampleTask {
manager: Arc::clone(&self.thread_manager),
store: Arc::clone(&self.thread_store),
parent,
samples: Arc::clone(&self.sample_responses),
params,
trace: context.request_trace(),
},
&self.background_tasks,
rpc_gate,
)
.await
}
pub(crate) async fn thread_revert(
&self,
request_id: ConnectionRequestId,
@@ -2887,13 +2928,17 @@ impl ThreadRequestProcessor {
params: ThreadLoadedListParams,
) -> Result<ThreadLoadedListResponse, JSONRPCErrorError> {
let ThreadLoadedListParams { cursor, limit } = params;
let mut data: Vec<String> = self
.thread_manager
.list_thread_ids()
.await
.into_iter()
.map(|thread_id| thread_id.to_string())
.collect();
let mut data = Vec::new();
for thread_id in self.thread_manager.list_thread_ids().await {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await
&& thread
.thread_extension_data()
.get::<SampleThread>()
.is_none()
{
data.push(thread_id.to_string());
}
}
if data.is_empty() {
return Ok(ThreadLoadedListResponse {