Expose loaded thread environments in app-server responses (#42386)

## What changed

- Add the experimental `Thread.environments` field with each selected environment's ID, working directory, and runtime workspace roots.
- Populate the field from live thread state in start, resume, read, list, and unarchive responses. Use `null` for unloaded threads and an empty list when a loaded thread has no environment selection.
- Document that the field reports selection rather than connection status and does not affect executor selection or resume behavior.

## Testing

- Cover selection updates, unloaded and resumed threads, loaded pathless unarchives, and clients that subscribe after environment connection notifications.

GitOrigin-RevId: a6daa06aff7089d7c5a1e4c7d03109b41f12fa3c
This commit is contained in:
Anton Panasenko
2026-09-02 23:41:14 +00:00
committed by copyberry
parent cff76fa96f
commit 2b554fd3f9
42 changed files with 681 additions and 11 deletions
@@ -242,6 +242,7 @@ fn sample_thread_with_metadata(
parent_thread_id: Option<String>,
) -> Thread {
Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: format!("session-{thread_id}"),
+1
View File
@@ -588,6 +588,7 @@ fn sample_thread_archive_request() -> ClientRequest {
fn sample_thread(thread_id: &str) -> Thread {
Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: format!("session-{thread_id}"),
@@ -4338,6 +4338,29 @@
],
"type": "object"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -20553,6 +20553,29 @@
"title": "ThreadDeletedNotification",
"type": "object"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -18267,6 +18267,29 @@
"title": "ThreadDeletedNotification",
"type": "object"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1485,6 +1485,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1485,6 +1485,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1485,6 +1485,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -1276,6 +1276,29 @@
],
"type": "string"
},
"ThreadEnvironment": {
"description": "An environment selected by a loaded thread, independent of connection status.",
"properties": {
"cwd": {
"$ref": "#/definitions/LegacyAppPathString"
},
"environmentId": {
"type": "string"
},
"runtimeWorkspaceRoots": {
"items": {
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
}
},
"required": [
"cwd",
"environmentId",
"runtimeWorkspaceRoots"
],
"type": "object"
},
"ThreadExtra": {
"description": "Extra app-server data for a thread.",
"type": "object"
@@ -0,0 +1,9 @@
// 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 { LegacyAppPathString } from "../LegacyAppPathString";
/**
* An environment selected by a loaded thread, independent of connection status.
*/
export type ThreadEnvironment = { environmentId: string, cwd: LegacyAppPathString, runtimeWorkspaceRoots: Array<LegacyAppPathString>, };
@@ -473,6 +473,7 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadDeleteParams } from "./ThreadDeleteParams";
export type { ThreadDeleteResponse } from "./ThreadDeleteResponse";
export type { ThreadDeletedNotification } from "./ThreadDeletedNotification";
export type { ThreadEnvironment } from "./ThreadEnvironment";
export type { ThreadExtra } from "./ThreadExtra";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
@@ -3128,6 +3128,7 @@ mod tests {
request_id: RequestId::Integer(7),
response: v2::ThreadStartResponse {
thread: v2::Thread {
environments: None,
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
extra: None,
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
@@ -3186,6 +3187,7 @@ mod tests {
"response": {
"thread": {
"id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
"environments": null,
"extra": null,
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
"forkedFromId": null,
@@ -1,9 +1,36 @@
use crate::JsonSchema;
use crate::TS;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use serde::Deserialize;
use serde::Serialize;
/// An environment selected by a loaded thread, independent of connection status.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadEnvironment {
pub environment_id: String,
pub cwd: LegacyAppPathString,
pub runtime_workspace_roots: Vec<LegacyAppPathString>,
}
impl From<&TurnEnvironmentSelection> for ThreadEnvironment {
fn from(selection: &TurnEnvironmentSelection) -> Self {
Self {
environment_id: selection.environment_id.clone(),
cwd: selection.cwd.clone().into(),
runtime_workspace_roots: selection
.workspace_roots
.iter()
.cloned()
.map(Into::into)
.collect(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -272,6 +272,13 @@ fn thread_resume_params_accept_turns_page_bootstrap() {
fn thread_resume_response_round_trips_initial_turns_page() {
let response = ThreadResumeResponse {
thread: Thread {
environments: Some(vec![ThreadEnvironment {
environment_id: "remote".to_string(),
cwd: LegacyAppPathString::from_string(r"C:\workspace"),
runtime_workspace_roots: vec![LegacyAppPathString::from_string(
r"C:\workspace\src",
)],
}]),
id: "thr_123".to_string(),
extra: None,
session_id: "thr_123".to_string(),
@@ -328,6 +335,12 @@ fn thread_resume_response_round_trips_initial_turns_page() {
};
let value = serde_json::to_value(&response).expect("serialize thread resume response");
assert_eq!(
value["thread"]["environments"],
json!([{
"environmentId": "remote", "cwd": r"C:\workspace", "runtimeWorkspaceRoots": [r"C:\workspace\src"]
}])
);
assert_eq!(
value["thread"]["section"],
json!({
@@ -345,11 +358,13 @@ fn thread_resume_response_round_trips_initial_turns_page() {
legacy_thread_fields.remove("section");
legacy_thread_fields.remove("sectionEnteredAt");
legacy_thread_fields.remove("projectId");
legacy_thread_fields.remove("environments");
let legacy_thread =
serde_json::from_value::<Thread>(legacy_thread).expect("deserialize legacy thread");
assert_eq!(legacy_thread.section, None);
assert_eq!(legacy_thread.section_entered_at, None);
assert_eq!(legacy_thread.project_id, None);
assert_eq!(legacy_thread.environments, None);
assert_eq!(
value.get("initialTurnsPage"),
@@ -1,4 +1,5 @@
use super::CodexErrorInfo;
use super::ThreadEnvironment;
use super::ThreadItem;
use super::ThreadStatus;
use super::TurnStatus;
@@ -203,6 +204,12 @@ pub struct ThreadSectionAppearance {
pub struct Thread {
/// Identifier for this thread. Codex-generated thread IDs are UUIDv7.
pub id: String,
/// Current environments for a loaded thread, in priority order, primary first.
/// `null` means the thread is not loaded or the server does not expose its selection.
/// An empty list means no environments are selected. This does not report connection status.
#[experimental("thread.environments")]
#[serde(default)]
pub environments: Option<Vec<ThreadEnvironment>>,
/// Optional implementation-specific thread data.
#[experimental("thread.extra")]
pub extra: Option<ThreadExtra>,
@@ -286,6 +293,8 @@ pub struct Thread {
#[serde(rename_all = "camelCase")]
struct ThreadCompatibility {
id: String,
#[serde(default)]
environments: Option<Vec<ThreadEnvironment>>,
extra: Option<ThreadExtra>,
session_id: String,
forked_from_id: Option<String>,
@@ -328,6 +337,7 @@ impl<'de> Deserialize<'de> for Thread {
let thread = ThreadCompatibility::deserialize(deserializer)?;
Ok(Self {
id: thread.id,
environments: thread.environments,
extra: thread.extra,
session_id: thread.session_id,
forked_from_id: thread.forked_from_id,
+7
View File
@@ -370,6 +370,13 @@ subscribing to it, or dispatching queued work or goal continuations.
Start a fresh thread when you need a new Codex conversation.
Experimental `Thread.environments` returns a loaded thread's current selection as `{ environmentId, cwd, runtimeWorkspaceRoots }` entries.
The first entry is the primary environment; paths use that environment's native syntax.
An empty list means no environments are selected; `null` means the thread is not loaded or the server does not expose its selection.
Start and resume responses report the resulting live selection, and read, list, and unarchive responses include it for loaded threads, even if the client missed `thread/environment/connected`.
The field is not persisted and does not change executor selection or resume behavior.
Reading an unloaded thread leaves it unloaded and returns `null`; use `environment/status` to check connection status separately.
```json
{ "method": "thread/start", "id": 10, "params": {
// Optionally set config settings. If not specified, will use the user's
@@ -3,7 +3,7 @@ use crate::error_code::invalid_request;
use crate::notification_media::without_notification_media;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::request_processors::apply_live_model_settings;
use crate::request_processors::apply_live_thread_settings;
use crate::request_processors::populate_thread_turns_from_history;
use crate::request_processors::thread_from_stored_thread;
use crate::request_processors::thread_settings_from_config_snapshot;
@@ -1287,7 +1287,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}
};
apply_live_model_settings(&mut response.thread, &config_snapshot);
apply_live_thread_settings(&mut response.thread, &config_snapshot);
outgoing.send_response(request_id, response).await;
}
}
@@ -605,12 +605,19 @@ use crate::thread_state::ThreadStateManager;
use token_usage_replay::restored_token_usage_turn_id;
use token_usage_replay::send_thread_token_usage_update_to_connection;
pub(crate) fn apply_live_model_settings(
pub(crate) fn apply_live_thread_settings(
thread: &mut Thread,
config_snapshot: &ThreadConfigSnapshot,
) {
thread.model = Some(config_snapshot.model.clone());
thread.reasoning_effort = config_snapshot.reasoning_effort.clone();
thread.environments = Some(
config_snapshot
.environment_selections()
.iter()
.map(Into::into)
.collect(),
);
}
fn resolve_request_cwd(cwd: Option<PathBuf>) -> Result<Option<AbsolutePathBuf>, JSONRPCErrorError> {
@@ -1,4 +1,4 @@
use super::apply_live_model_settings;
use super::apply_live_thread_settings;
use super::thread_input::can_accept_direct_input;
use crate::thread_status::ThreadWatchManager;
use crate::thread_status::resolve_thread_status;
@@ -44,7 +44,7 @@ pub(super) async fn enrich_loaded_threads<T>(
return;
};
let config_snapshot = loaded_thread.config_snapshot().await;
apply_live_model_settings(thread, &config_snapshot);
apply_live_thread_settings(thread, &config_snapshot);
if !matches!(
&thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
@@ -1994,7 +1994,7 @@ impl ThreadRequestProcessor {
if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
let config_snapshot = loaded_thread.config_snapshot().await;
apply_live_model_settings(&mut thread, &config_snapshot);
apply_live_thread_settings(&mut thread, &config_snapshot);
}
self.attach_thread_name(thread_uuid, &mut thread).await;
thread.status = resolve_thread_status(
@@ -2049,6 +2049,10 @@ impl ThreadRequestProcessor {
let (mut thread, _) =
thread_from_stored_thread(stored_thread, fallback_provider.as_str(), &self.config.cwd);
if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_id).await {
let config_snapshot = loaded_thread.config_snapshot().await;
apply_live_thread_settings(&mut thread, &config_snapshot);
}
thread.status = resolve_thread_status(
self.thread_watch_manager
.loaded_status_for_thread(&thread.id)
@@ -2985,7 +2989,7 @@ impl ThreadRequestProcessor {
} else {
fallback_thread
};
apply_live_model_settings(&mut thread, &config_snapshot);
apply_live_thread_settings(&mut thread, &config_snapshot);
self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread)
.await?;
Ok(thread)
@@ -4284,7 +4288,7 @@ impl ThreadRequestProcessor {
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
thread_summary.thread_source = config_snapshot.thread_source.clone().map(Into::into);
apply_live_model_settings(&mut thread_summary, &config_snapshot);
apply_live_thread_settings(&mut thread_summary, &config_snapshot);
thread_summary.can_accept_direct_input = Some(can_accept_direct_input(
existing_thread.multi_agent_version(),
&config_snapshot.session_source,
@@ -4649,7 +4653,7 @@ impl ThreadRequestProcessor {
)),
};
let mut thread = thread?;
apply_live_model_settings(&mut thread, &config_snapshot);
apply_live_thread_settings(&mut thread, &config_snapshot);
thread.can_accept_direct_input = Some(can_accept_direct_input);
thread.id = thread_id.to_string();
thread.session_id = session_id;
@@ -5147,7 +5151,7 @@ impl ThreadRequestProcessor {
));
thread.session_id = session_configured.session_id.to_string();
thread.thread_source = config_snapshot.thread_source.clone().map(Into::into);
apply_live_model_settings(&mut thread, &config_snapshot);
apply_live_thread_settings(&mut thread, &config_snapshot);
if thread.path.is_none() {
thread.project_id = inherited_project_id.clone();
}
@@ -5908,6 +5912,7 @@ pub(crate) fn thread_from_stored_thread(
let thread_id = thread.thread_id.to_string();
let thread = Thread {
id: thread_id.clone(),
environments: None,
extra: None,
session_id: thread_id,
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
@@ -6096,6 +6101,13 @@ fn build_thread_from_snapshot(
let now = time::OffsetDateTime::now_utc().unix_timestamp();
Thread {
id: thread_id.to_string(),
environments: Some(
config_snapshot
.environment_selections()
.iter()
.map(Into::into)
.collect(),
),
extra: None,
session_id,
forked_from_id: None,
@@ -198,6 +198,7 @@ mod tests {
fn test_thread(items: Vec<ThreadItem>) -> Thread {
Thread {
environments: None,
id: "thread-1".to_string(),
extra: None,
session_id: "session-1".to_string(),
@@ -259,6 +259,7 @@ pub(crate) fn summary_to_thread(
let thread_id = conversation_id.to_string();
Thread {
environments: None,
id: thread_id.clone(),
extra: None,
session_id: thread_id,
@@ -1469,7 +1469,7 @@ impl TurnRequestProcessor {
if let Some(mut thread) = stored_thread {
let config_snapshot = review_thread.config_snapshot().await;
apply_live_model_settings(&mut thread, &config_snapshot);
apply_live_thread_settings(&mut thread, &config_snapshot);
thread.session_id = review_thread.session_configured().session_id.to_string();
self.thread_watch_manager
.upsert_thread_silently(&thread.id)
@@ -97,6 +97,7 @@ mod skills_list;
mod sleep;
mod thread_archive;
mod thread_delete;
mod thread_environments;
mod thread_fork;
mod thread_inject_items;
mod thread_list;
@@ -0,0 +1,231 @@
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
use codex_app_server_protocol::ThreadEnvironment;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT;
use super::connection_handling_websocket::connect_websocket;
use super::connection_handling_websocket::create_config_toml;
use super::connection_handling_websocket::read_notification_for_method;
use super::connection_handling_websocket::read_response_for_id;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
#[tokio::test]
async fn thread_environments_follow_the_loaded_thread_selection() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri()).write(home.path())?;
let mut first = TestAppServer::builder()
.with_codex_home(home.path())
.build_initialized()
.await?;
let selection = first.auto_env_params()?;
let expected = Some(vec![ThreadEnvironment {
environment_id: selection.environment_id,
cwd: selection.cwd.clone(),
runtime_workspace_roots: vec![selection.cwd],
}]);
let request = first
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let started: ThreadStartResponse =
timeout(DEFAULT_READ_TIMEOUT, first.read_response(request)).await??;
assert_eq!(started.thread.environments, expected);
let thread_id = started.thread.id;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
first.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.clone(),
input: vec![UserInput::Text {
text: "hello".into(),
text_elements: vec![],
}],
..Default::default()
}),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_read_and_list_environments(&mut first, &thread_id, &expected).await?;
// Report the thread's explicit selection rather than the server-wide defaults.
let completed = timeout(
DEFAULT_READ_TIMEOUT,
first.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.clone(),
input: vec![UserInput::Text {
text: "hello again".into(),
text_elements: vec![],
}],
environments: Some(vec![]),
..Default::default()
}),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_read_and_list_environments(&mut first, &thread_id, &Some(vec![])).await?;
let request = first
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
let resumed: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, first.read_response(request)).await??;
assert_eq!(resumed.thread.environments, Some(vec![]));
timeout(DEFAULT_READ_TIMEOUT, first.shutdown_gracefully()).await??;
// Unloaded reads have no live selection. Resume reports its newly selected defaults.
let mut second = TestAppServer::builder()
.with_codex_home(home.path())
.build_initialized()
.await?;
assert_read_and_list_environments(&mut second, &thread_id, &None).await?;
let request = second
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
let resumed: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, second.read_response(request)).await??;
let expected = Some(vec![ThreadEnvironment {
environment_id: second.auto_env_params()?.environment_id,
cwd: resumed.cwd.clone().into(),
runtime_workspace_roots: resumed
.runtime_workspace_roots
.iter()
.cloned()
.map(Into::into)
.collect(),
}]);
assert_eq!(resumed.thread.environments, expected);
assert_read_and_list_environments(&mut second, &thread_id, &expected).await?;
let request = second
.send_thread_resume_request(ThreadResumeParams {
thread_id,
exclude_turns: true,
..Default::default()
})
.await?;
let resumed: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, second.read_response(request)).await??;
assert_eq!(resumed.thread.environments, expected);
Ok(())
}
async fn assert_read_and_list_environments(
server: &mut TestAppServer,
thread_id: &str,
expected: &Option<Vec<ThreadEnvironment>>,
) -> Result<()> {
let request = server
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.to_owned(),
include_turns: false,
})
.await?;
let read: ThreadReadResponse =
timeout(DEFAULT_READ_TIMEOUT, server.read_response(request)).await??;
assert_eq!(&read.thread.environments, expected);
let request = server
.send_thread_list_request(serde_json::from_value::<ThreadListParams>(json!({}))?)
.await?;
let listed: ThreadListResponse =
timeout(DEFAULT_READ_TIMEOUT, server.read_response(request)).await??;
assert_eq!(
&listed
.data
.iter()
.find(|thread| thread.id == thread_id)
.expect("listed thread")
.environments,
expected
);
Ok(())
}
#[tokio::test]
async fn thread_environments_are_returned_to_a_late_subscriber() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let home = TempDir::new()?;
create_config_toml(home.path(), &responses.uri(), "never")?;
let workspace = TempDir::new()?;
let cwd = AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?;
let expected =
json!([{ "environmentId": "local", "cwd": cwd, "runtimeWorkspaceRoots": [cwd] }]);
let (mut process, address) = spawn_websocket_server(home.path()).await?;
let mut first = connect_websocket(address).await?;
let initialize = json!({
"clientInfo": { "name": "environment-test", "version": "1" },
"capabilities": { "experimentalApi": true }
});
send_request(
&mut first,
"initialize",
/*id*/ 1,
Some(initialize.clone()),
)
.await?;
read_response_for_id(&mut first, /*id*/ 1).await?;
send_request(
&mut first,
"thread/start",
/*id*/ 2,
Some(json!({
"environments": [{ "environmentId": "local", "cwd": cwd }]
})),
)
.await?;
let started = read_response_for_id(&mut first, /*id*/ 2).await?;
assert_eq!(started.result["thread"]["environments"], expected);
let thread_id = started.result["thread"]["id"].as_str().unwrap();
send_request(
&mut first,
"turn/start",
/*id*/ 3,
Some(json!({
"threadId": thread_id, "input": [{ "type": "text", "text": "hello" }]
})),
)
.await?;
read_response_for_id(&mut first, /*id*/ 3).await?;
read_notification_for_method(&mut first, "turn/completed").await?;
let mut second = connect_websocket(address).await?;
send_request(&mut second, "initialize", /*id*/ 1, Some(initialize)).await?;
read_response_for_id(&mut second, /*id*/ 1).await?;
for (id, method, params) in [
(2, "thread/read", json!({ "threadId": thread_id })),
(
3,
"thread/resume",
json!({ "threadId": thread_id, "excludeTurns": true }),
),
] {
send_request(&mut second, method, id, Some(params)).await?;
let response = read_response_for_id(&mut second, id).await?;
assert_eq!(response.result["thread"]["environments"], expected);
}
process.kill().await?;
Ok(())
}
@@ -332,6 +332,37 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
assert_eq!(thread.path, None);
assert_eq!(thread.forked_from_id, Some(parent_thread_id.to_string()));
assert_eq!(thread.name, Some("named pathless thread".to_string()));
assert_eq!(thread.environments, None);
// Pathless stores can return an unarchived thread while it is still loaded.
for (request_id, environments) in [(2, None), (4, Some(vec![]))] {
let result = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(request_id),
params: ThreadStartParams {
model: Some("mock-model".to_string()),
environments,
..Default::default()
},
})
.await?
.expect("thread/start should succeed");
let ThreadStartResponse {
thread: started, ..
} = serde_json::from_value(result)?;
assert!(started.environments.is_some());
let result = client
.request(ClientRequest::ThreadUnarchive {
request_id: RequestId::Integer(request_id + 1),
params: ThreadUnarchiveParams {
thread_id: started.id,
},
})
.await?
.expect("thread/unarchive should succeed for a loaded pathless thread");
let ThreadUnarchiveResponse { thread } = serde_json::from_value(result)?;
assert_eq!(thread.environments, started.environments);
}
client.shutdown().await?;
Ok(())
+2
View File
@@ -351,6 +351,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() {
#[test]
fn turn_items_for_thread_returns_matching_turn_items() {
let thread = AppServerThread {
environments: None,
id: "thread-1".to_string(),
extra: None,
session_id: "thread-1".to_string(),
@@ -841,6 +842,7 @@ async fn session_configured_from_thread_response_preserves_parent_thread_id() {
fn sample_thread_start_response() -> ThreadStartResponse {
ThreadStartResponse {
thread: codex_app_server_protocol::Thread {
environments: None,
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
extra: None,
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
@@ -39,6 +39,7 @@ fn overview_thread(
status: ThreadStatus,
) -> Thread {
Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
project_id: None,
+1
View File
@@ -135,6 +135,7 @@ mod tests {
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),
+3
View File
@@ -4039,6 +4039,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
agent_thread_id,
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
environments: None,
id: agent_thread_id.to_string(),
extra: None,
session_id: agent_thread_id.to_string(),
@@ -4140,6 +4141,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
agent_thread_id,
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
environments: None,
id: agent_thread_id.to_string(),
extra: None,
session_id: agent_thread_id.to_string(),
@@ -4208,6 +4210,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
app.primary_session_configured = Some(primary_session);
let thread = Thread {
environments: None,
id: read_thread_id.to_string(),
extra: None,
session_id: read_thread_id.to_string(),
+1
View File
@@ -933,6 +933,7 @@ async fn known_thread_started_preserves_session_without_reading_unmaterialized_r
);
let notification = ThreadStartedNotification {
thread: Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),
@@ -410,6 +410,7 @@ mod tests {
..test_thread_session(primary_thread_id, test_path_buf("/tmp/primary"))
};
let read_thread = Thread {
environments: None,
id: read_thread_id.to_string(),
extra: None,
session_id: read_thread_id.to_string(),
+1
View File
@@ -3686,6 +3686,7 @@ mod tests {
let read_only_profile = PermissionProfile::read_only();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: ThreadId::new().to_string(),
+4
View File
@@ -6266,6 +6266,7 @@ session_picker_view = "dense"
fn app_server_row_keeps_pathless_threads() {
let thread_id = ThreadId::new();
let thread = Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),
@@ -6310,6 +6311,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),
@@ -6394,6 +6396,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),
@@ -6469,6 +6472,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
environments: None,
id: thread_id.to_string(),
extra: None,
session_id: thread_id.to_string(),