feat(app-server): move v2 sessionId onto Thread (#21336)

## Why

`session_id` and `thread_id` are separate identities after #20437, but
app-server only surfaced `sessionId` on the `thread/start`,
`thread/resume`, and `thread/fork` response envelopes. Other
thread-bearing surfaces such as `thread/list`, `thread/read`,
`thread/started`, `thread/rollback`, `thread/metadata/update`, and
`thread/unarchive` either lacked the grouping key or forced clients to
special-case those three responses.

Making `sessionId` part of the reusable `Thread` payload gives every v2
API surface one place to expose session-tree identity.

## Mental model
  1. thread.sessionId lives on `Thread`
2. It is a view/runtime identity for the current live session tree, not
durable stored lineage metadata
3. When app-server has a live loaded thread, it copies the real value
from core’s session_configured.session_id
4. When it only has stored/unloaded data, it falls back to
thread.sessionId = thread.id

## What changed

- Added `sessionId` to the v2
[`Thread`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs#L105-L109).
- Removed the duplicate top-level `sessionId` fields from
`thread/start`, `thread/resume`, and `thread/fork`; clients should now
read `response.thread.sessionId`.
- Populated `thread.sessionId` when building live thread responses,
replaying loaded threads, and returning stored-thread summaries so the
field is present across start, resume, fork, list, read, rollback,
metadata-update, unarchive, and `thread/started` paths. See
[`load_thread_from_resume_source_or_send_internal`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/src/request_processors/thread_processor.rs#L2824-L2918)
and
[`thread_from_stored_thread`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/src/request_processors/thread_processor.rs#L3671-L3719).
- Preserved the stored-thread fallback: if a thread has not been loaded
into a live session tree yet, `thread.sessionId` falls back to
`thread.id`; once the thread is live again, the field reports the active
session tree root.
- Regenerated the JSON/TypeScript schemas and updated the app-server
README examples to show
[`thread.sessionId`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/README.md#L306-L310)
on the thread object.
This commit is contained in:
jif-oai
2026-05-06 15:23:25 +02:00
committed by GitHub
Unverified
parent ca257b6ce5
commit 5ecff05196
43 changed files with 186 additions and 112 deletions
@@ -128,6 +128,7 @@ fn sample_thread_with_metadata(
) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: format!("session-{thread_id}"),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral,
@@ -154,7 +155,6 @@ fn sample_thread_start_response(
model: &str,
) -> ClientResponsePayload {
ClientResponsePayload::ThreadStart(ThreadStartResponse {
session_id: format!("session-{thread_id}"),
thread: sample_thread_with_metadata(
thread_id,
ephemeral,
@@ -216,7 +216,6 @@ fn sample_thread_resume_response_with_source(
thread_source: Option<AppServerThreadSource>,
) -> ClientResponsePayload {
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
session_id: format!("session-{thread_id}"),
thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source),
model: model.to_string(),
model_provider: "openai".to_string(),
+1 -3
View File
@@ -76,6 +76,7 @@ fn sample_thread_archive_request() -> ClientRequest {
fn sample_thread(thread_id: &str) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: format!("session-{thread_id}"),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral: false,
@@ -102,7 +103,6 @@ fn sample_permission_profile() -> AppServerPermissionProfile {
fn sample_thread_start_response() -> ClientResponsePayload {
ClientResponsePayload::ThreadStart(ThreadStartResponse {
session_id: "session-1".to_string(),
thread: sample_thread("thread-1"),
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
@@ -120,7 +120,6 @@ fn sample_thread_start_response() -> ClientResponsePayload {
fn sample_thread_resume_response() -> ClientResponsePayload {
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
session_id: "session-2".to_string(),
thread: sample_thread("thread-2"),
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
@@ -138,7 +137,6 @@ fn sample_thread_resume_response() -> ClientResponsePayload {
fn sample_thread_fork_response() -> ClientResponsePayload {
ClientResponsePayload::ThreadFork(ThreadForkResponse {
session_id: "session-3".to_string(),
thread: sample_thread("thread-3"),
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
@@ -3072,6 +3072,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -3120,6 +3124,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -15345,6 +15345,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -15393,6 +15397,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -15664,11 +15669,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -17176,11 +17176,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -17504,11 +17499,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -13231,6 +13231,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -13279,6 +13283,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -13550,11 +13555,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -15062,11 +15062,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -15390,11 +15385,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -1403,6 +1403,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1455,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2619,11 +2624,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -1403,6 +1403,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1455,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2619,11 +2624,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -1403,6 +1403,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1455,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2619,11 +2624,6 @@
}
]
},
"sessionId": {
"default": "",
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -9,6 +9,10 @@ import type { ThreadStatus } from "./ThreadStatus";
import type { Turn } from "./Turn";
export type Thread = { id: string,
/**
* Session id shared by threads that belong to the same session tree.
*/
sessionId: string,
/**
* Source thread id when this thread was created by forking another thread.
*/
@@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = {/**
* Session id shared by threads that belong to the same session tree.
*/
sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = {/**
* Session id shared by threads that belong to the same session tree.
*/
sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = {/**
* Session id shared by threads that belong to the same session tree.
*/
sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -2176,9 +2176,9 @@ mod tests {
let response = ClientResponse::ThreadStart {
request_id: RequestId::Integer(7),
response: v2::ThreadStartResponse {
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
thread: v2::Thread {
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral: true,
@@ -2218,9 +2218,9 @@ mod tests {
"method": "thread/start",
"id": 7,
"response": {
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
"thread": {
"id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
"forkedFromId": null,
"preview": "first prompt",
"ephemeral": true,
@@ -3437,10 +3437,11 @@ fn thread_start_params_preserve_explicit_null_service_tier() {
}
#[test]
fn thread_lifecycle_responses_default_missing_compat_fields() {
fn thread_lifecycle_responses_default_missing_optional_fields() {
let response = json!({
"thread": {
"id": "thread-id",
"sessionId": "thread-id",
"forkedFromId": null,
"preview": "",
"ephemeral": false,
@@ -189,9 +189,6 @@ pub struct MockExperimentalMethodResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadStartResponse {
/// Session id shared by threads that belong to the same session tree.
#[serde(default)]
pub session_id: String,
pub thread: Thread,
pub model: String,
pub model_provider: String,
@@ -307,9 +304,6 @@ pub struct ThreadResumeParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadResumeResponse {
/// Session id shared by threads that belong to the same session tree.
#[serde(default)]
pub session_id: String,
pub thread: Thread,
pub model: String,
pub model_provider: String,
@@ -419,9 +413,6 @@ pub struct ThreadForkParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadForkResponse {
/// Session id shared by threads that belong to the same session tree.
#[serde(default)]
pub session_id: String,
pub thread: Thread,
pub model: String,
pub model_provider: String,
@@ -104,6 +104,8 @@ pub struct GitInfo {
#[ts(export_to = "v2/")]
pub struct Thread {
pub id: String,
/// Session id shared by threads that belong to the same session tree.
pub session_id: String,
/// Source thread id when this thread was created by forking another thread.
pub forked_from_id: Option<String>,
/// Usually the first user message in the thread, if available.
+2 -2
View File
@@ -303,11 +303,11 @@ Example:
{ "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } }
```
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The response includes the forked thread's `sessionId`, so clients do not need to infer it from the new thread id. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
```json
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
{ "id": 12, "result": { "sessionId": "thr_456", "thread": { "id": "thr_456", … } } }
{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } }
{ "method": "thread/started", "params": { "thread": { … } } }
```
@@ -1190,6 +1190,7 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
let response = match thread_rollback_response_from_stored_thread(
stored_thread,
conversation.session_configured().session_id.to_string(),
fallback_model_provider.as_str(),
&fallback_cwd,
loaded_status,
@@ -1543,6 +1544,7 @@ async fn handle_thread_rollback_failed(
fn thread_rollback_response_from_stored_thread(
stored_thread: codex_thread_store::StoredThread,
session_id: String,
fallback_model_provider: &str,
fallback_cwd: &AbsolutePathBuf,
loaded_status: ThreadStatus,
@@ -1550,6 +1552,7 @@ fn thread_rollback_response_from_stored_thread(
let thread_id = stored_thread.thread_id;
let (mut thread, history) =
thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd);
thread.session_id = session_id;
let Some(history) = history else {
return Err(format!(
"thread {thread_id} did not include persisted history after rollback"
@@ -2200,6 +2203,7 @@ mod tests {
let response = thread_rollback_response_from_stored_thread(
stored_thread,
thread_id.to_string(),
"fallback-provider",
&fallback_cwd,
ThreadStatus::NotLoaded,
@@ -599,9 +599,9 @@ pub(super) async fn handle_pending_thread_resume_request(
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
let session_id = conversation.session_configured().session_id.to_string();
thread.session_id = session_id;
let response = ThreadResumeResponse {
session_id,
thread,
model,
model_provider: model_provider_id,
@@ -1096,6 +1096,7 @@ impl ThreadRequestProcessor {
.await;
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
&config_snapshot,
session_configured.rollout_path.clone(),
);
@@ -1148,7 +1149,6 @@ impl ThreadRequestProcessor {
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
let response = ThreadStartResponse {
session_id: session_configured.session_id.to_string(),
thread: thread.clone(),
model: config_snapshot.model,
model_provider: config_snapshot.model_provider_id,
@@ -1529,9 +1529,9 @@ impl ThreadRequestProcessor {
..Default::default()
};
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let updated_thread = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
if let Some(loaded_thread) = loaded_thread.as_ref() {
if loaded_thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
@@ -1552,12 +1552,14 @@ impl ThreadRequestProcessor {
}
.map_err(|err| thread_store_write_error("update thread metadata", err))?
};
let (mut thread, _) = thread_from_stored_thread(
updated_thread,
self.config.model_provider_id.as_str(),
&self.config.cwd,
);
if let Some(loaded_thread) = loaded_thread.as_ref() {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
}
self.attach_thread_name(thread_uuid, &mut thread).await;
thread.status = resolve_thread_status(
self.thread_watch_manager
@@ -1603,18 +1605,16 @@ impl ThreadRequestProcessor {
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let fallback_provider = self.config.model_provider_id.clone();
let mut thread = self
let stored_thread = self
.thread_store
.unarchive_thread(StoreArchiveThreadParams { thread_id })
.await
.map_err(|err| thread_store_archive_error("unarchive", err))
.and_then(|stored_thread| {
summary_from_stored_thread(stored_thread, fallback_provider.as_str())
.map(|summary| summary_to_thread(summary, &self.config.cwd))
.ok_or_else(|| {
internal_error(format!("failed to read unarchived thread {thread_id}"))
})
.map_err(|err| thread_store_archive_error("unarchive", err))?;
let summary = summary_from_stored_thread(stored_thread, fallback_provider.as_str())
.ok_or_else(|| {
internal_error(format!("failed to read unarchived thread {thread_id}"))
})?;
let mut thread = summary_to_thread(summary, &self.config.cwd);
thread.status = resolve_thread_status(
self.thread_watch_manager
@@ -2040,6 +2040,7 @@ impl ThreadRequestProcessor {
if thread.path.is_none() {
thread.path = fallback_thread.path.clone();
}
thread.session_id.clone_from(&fallback_thread.session_id);
thread.ephemeral = fallback_thread.ephemeral;
thread
} else {
@@ -2223,8 +2224,12 @@ impl ThreadRequestProcessor {
) {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
let config_snapshot = thread.config_snapshot().await;
let loaded_thread =
build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path());
let loaded_thread = build_thread_from_snapshot(
thread_id,
thread.session_configured().session_id.to_string(),
&config_snapshot,
thread.rollout_path(),
);
self.thread_watch_manager.upsert_thread(loaded_thread).await;
}
@@ -2477,7 +2482,6 @@ impl ThreadRequestProcessor {
);
let response = ThreadResumeResponse {
session_id: session_configured.session_id.to_string(),
thread,
model: session_configured.model,
model_provider: session_configured.model_provider_id,
@@ -2651,11 +2655,12 @@ impl ThreadRequestProcessor {
}
let mut summary_source_thread = source_thread;
summary_source_thread.history = None;
let thread_summary = self.stored_thread_to_api_thread(
let mut thread_summary = self.stored_thread_to_api_thread(
summary_source_thread,
config_snapshot.model_provider_id.as_str(),
/*include_turns*/ false,
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
let mut config_for_instruction_sources = self.config.as_ref().clone();
config_for_instruction_sources.cwd = config_snapshot.cwd.clone();
let instruction_sources =
@@ -2824,6 +2829,7 @@ impl ThreadRequestProcessor {
include_turns: bool,
) -> std::result::Result<Thread, String> {
let config_snapshot = thread.config_snapshot().await;
let session_id = thread.session_configured().session_id.to_string();
let thread = match thread_history {
InitialHistory::Resumed(resumed) => {
let fallback_provider = config_snapshot.model_provider_id.as_str();
@@ -2885,6 +2891,7 @@ impl ThreadRequestProcessor {
InitialHistory::Forked(items) => {
let mut thread = build_thread_from_snapshot(
thread_id,
session_id.clone(),
&config_snapshot,
Some(rollout_path.into()),
);
@@ -2897,6 +2904,7 @@ impl ThreadRequestProcessor {
};
let mut thread = thread?;
thread.id = thread_id.to_string();
thread.session_id = session_id;
thread.path = Some(rollout_path.to_path_buf());
if include_turns {
let history_items = thread_history.get_rollout_items();
@@ -3078,8 +3086,12 @@ impl ThreadRequestProcessor {
} else {
let config_snapshot = forked_thread.config_snapshot().await;
// forked thread names do not inherit the source thread name
let mut thread =
build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None);
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
&config_snapshot,
/*path*/ None,
);
thread.preview = preview_from_rollout_items(&history_items);
thread.forked_from_id = Some(source_thread_id.to_string());
if include_turns {
@@ -3091,6 +3103,7 @@ impl ThreadRequestProcessor {
}
thread
};
thread.session_id = session_configured.session_id.to_string();
thread.thread_source = forked_thread
.config_snapshot()
.await
@@ -3116,7 +3129,6 @@ impl ThreadRequestProcessor {
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
let response = ThreadForkResponse {
session_id: session_configured.session_id.to_string(),
thread: thread.clone(),
model: session_configured.model,
model_provider: session_configured.model_provider_id,
@@ -3680,8 +3692,10 @@ pub(crate) fn thread_from_stored_thread(
thread.agent_role.clone(),
);
let history = thread.history;
let thread_id = thread.thread_id.to_string();
let thread = Thread {
id: thread.thread_id.to_string(),
id: thread_id.clone(),
session_id: thread_id,
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
preview: thread.first_user_message.unwrap_or(thread.preview),
ephemeral: false,
@@ -3878,12 +3892,14 @@ fn permission_profile_trusts_project(
fn build_thread_from_snapshot(
thread_id: ThreadId,
session_id: String,
config_snapshot: &ThreadConfigSnapshot,
path: Option<PathBuf>,
) -> Thread {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
Thread {
id: thread_id.to_string(),
session_id,
forked_from_id: None,
preview: String::new(),
ephemeral: config_snapshot.ephemeral,
@@ -3909,7 +3925,12 @@ fn build_thread_from_loaded_snapshot(
config_snapshot: &ThreadConfigSnapshot,
loaded_thread: &CodexThread,
) -> Thread {
build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path())
build_thread_from_snapshot(
thread_id,
loaded_thread.session_configured().session_id.to_string(),
config_snapshot,
loaded_thread.rollout_path(),
)
}
#[cfg(test)]
@@ -263,8 +263,10 @@ pub(crate) fn summary_to_thread(
fallback_cwd.clone()
});
let thread_id = conversation_id.to_string();
Thread {
id: conversation_id.to_string(),
id: thread_id.clone(),
session_id: thread_id,
forked_from_id: None,
preview,
ephemeral: false,
@@ -935,6 +935,7 @@ impl TurnRequestProcessor {
Ok(stored_thread) => {
let (mut thread, _) =
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
thread.session_id = review_thread.session_configured().session_id.to_string();
self.thread_watch_manager
.upsert_thread_silently(thread.clone())
.await;
+1
View File
@@ -889,6 +889,7 @@ mod tests {
fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -363,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
let started: ThreadStartedNotification =
serde_json::from_value(notification.params.expect("params must be present"))?;
assert_eq!(started.thread.id, review_thread_id);
assert_eq!(started.thread.session_id, review_thread_id);
Ok(())
}
@@ -101,20 +101,28 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
)
.await??;
let fork_result = fork_resp.result.clone();
let ThreadForkResponse {
session_id, thread, ..
} = to_response::<ThreadForkResponse>(fork_resp)?;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
// Wire contract: thread title field is `name`, serialized as null when unset.
let thread_json = fork_result
.get("thread")
.and_then(Value::as_object)
.expect("thread/fork result.thread must be an object");
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str()),
"forked threads should serialize `sessionId` on the thread object"
);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"forked threads do not inherit a name; expected `name: null`"
);
assert_eq!(
fork_result.get("sessionId"),
None,
"thread/fork should not serialize a top-level `sessionId`"
);
let after_contents = std::fs::read_to_string(&original_path)?;
assert_eq!(
@@ -123,7 +131,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
);
assert_ne!(thread.id, conversation_id);
assert_eq!(session_id, thread.id);
assert_eq!(thread.session_id, thread.id);
assert_eq!(thread.forked_from_id, Some(conversation_id.clone()));
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
@@ -879,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()
assert_eq!(ids, vec![subagent_id.as_str()]);
assert_ne!(cli_id, subagent_id);
assert!(matches!(data[0].source, SessionSource::SubAgent(_)));
assert_eq!(data[0].session_id, subagent_id);
Ok(())
}
@@ -77,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
to_response::<ThreadMetadataUpdateResponse>(update_resp)?;
assert_eq!(updated.id, thread.id);
assert_eq!(updated.session_id, thread.session_id);
assert_eq!(
updated.git_info,
Some(GitInfo {
@@ -90,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
.get("thread")
.and_then(Value::as_object)
.expect("thread/metadata/update result.thread must be an object");
assert_eq!(
updated_thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str())
);
let updated_git_info_json = updated_thread_json
.get("gitInfo")
.and_then(Value::as_object)
@@ -270,10 +270,11 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
session_id, thread, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert!(!session_id.is_empty(), "session id should not be empty");
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert!(
!thread.session_id.is_empty(),
"session id should not be empty"
);
assert_eq!(thread.thread_source, Some(ThreadSource::User));
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
@@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
.and_then(Value::as_object)
.expect("thread/rollback result.thread must be an object");
assert_eq!(rolled_back_thread.name, None);
assert_eq!(rolled_back_thread.session_id, thread.session_id);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"thread/rollback must serialize `name: null` when unset"
);
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str())
);
assert_eq!(rolled_back_thread.turns.len(), 1);
assert_eq!(rolled_back_thread.status, ThreadStatus::Idle);
@@ -121,12 +121,14 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
.await??;
let resp_result = resp.result.clone();
let ThreadStartResponse {
session_id,
thread,
model_provider,
..
} = to_response::<ThreadStartResponse>(resp)?;
assert!(!session_id.is_empty(), "session id should not be empty");
assert!(
!thread.session_id.is_empty(),
"session id should not be empty"
);
assert!(!thread.id.is_empty(), "thread id should not be empty");
assert!(
thread.preview.is_empty(),
@@ -155,11 +157,21 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
.get("thread")
.and_then(Value::as_object)
.expect("thread/start result.thread must be an object");
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str()),
"new threads should serialize `sessionId` on the thread object"
);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"new threads should serialize `name: null`"
);
assert_eq!(
resp_result.get("sessionId"),
None,
"thread/start should not serialize a top-level `sessionId`"
);
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
+2 -2
View File
@@ -1060,7 +1060,7 @@ fn session_configured_from_thread_start_response(
config: &Config,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.session_id,
&response.thread.session_id,
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
@@ -1085,7 +1085,7 @@ fn session_configured_from_thread_resume_response(
config: &Config,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.session_id,
&response.thread.session_id,
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
+2 -1
View File
@@ -244,6 +244,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() {
fn turn_items_for_thread_returns_matching_turn_items() {
let thread = AppServerThread {
id: "thread-1".to_string(),
session_id: "thread-1".to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -473,9 +474,9 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re
fn sample_thread_start_response() -> ThreadStartResponse {
ThreadStartResponse {
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
thread: codex_app_server_protocol::Thread {
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
+1
View File
@@ -118,6 +118,7 @@ mod tests {
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
+4
View File
@@ -2940,6 +2940,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
@@ -3022,6 +3023,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
@@ -3077,6 +3079,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
let thread = Thread {
id: read_thread_id.to_string(),
session_id: read_thread_id.to_string(),
forked_from_id: None,
preview: "read thread".to_string(),
ephemeral: false,
@@ -5041,6 +5044,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
&ThreadRollbackResponse {
thread: Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -322,6 +322,7 @@ mod tests {
};
let read_thread = Thread {
id: read_thread_id.to_string(),
session_id: read_thread_id.to_string(),
forked_from_id: None,
preview: "read thread".to_string(),
ephemeral: false,
+1 -1
View File
@@ -1813,9 +1813,9 @@ mod tests {
let forked_from_id = ThreadId::new();
let read_only_profile = PermissionProfile::read_only();
let response = ThreadResumeResponse {
session_id: ThreadId::new().to_string(),
thread: codex_app_server_protocol::Thread {
id: thread_id.to_string(),
session_id: ThreadId::new().to_string(),
forked_from_id: Some(forked_from_id.to_string()),
preview: "hello".to_string(),
ephemeral: false,
+4
View File
@@ -5684,6 +5684,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::from("remote thread"),
ephemeral: false,
@@ -5717,6 +5718,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::from("preview"),
ephemeral: false,
@@ -5783,6 +5785,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::from("preview"),
ephemeral: false,
@@ -5839,6 +5842,7 @@ session_picker_view = "dense"
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::from("preview"),
ephemeral: false,