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
@@ -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,