From 06e5dfa4dd9fc6fb30d65553757060428c9102c2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 12:04:27 +0200 Subject: [PATCH] feat: return session ID from thread/fork (#21332) ## Why `thread/start` and `thread/resume` already return `sessionId`, but `thread/fork` only returned the new thread. That left clients to infer the forked thread's session identity from `thread.id`, which kept the new `session_id` / `thread_id` split implicit at one lifecycle boundary. Follow-up to #20437. ## What changed - Add `sessionId` to `ThreadForkResponse`. - Populate it from the forked session configuration. - Regenerate the v2 JSON/TypeScript schema fixtures and update the app-server docs/example. - Extend the fork integration test to assert the returned `sessionId`. ## Verification - Added coverage in `thread_fork_creates_new_thread_and_emits_started` for the new response field. --- codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/codex_app_server_protocol.schemas.json | 5 +++++ .../schema/json/codex_app_server_protocol.v2.schemas.json | 5 +++++ .../schema/json/v2/ThreadForkResponse.json | 5 +++++ .../schema/typescript/v2/ThreadForkResponse.ts | 5 ++++- codex-rs/app-server-protocol/src/protocol/v2/thread.rs | 3 +++ codex-rs/app-server/README.md | 4 ++-- .../app-server/src/request_processors/thread_processor.rs | 1 + codex-rs/app-server/tests/suite/v2/thread_fork.rs | 5 ++++- 9 files changed, 30 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 42d508602a..510378c20d 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -138,6 +138,7 @@ 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(), diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d6f184a6db..20279bf010 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15664,6 +15664,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/v2/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 1f252cbd23..50a19200a5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13550,6 +13550,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 05f9849df3..902a6f4c9f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2619,6 +2619,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..b0bb9caea6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -9,7 +9,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +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, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index d8250987a6..5a09c2e665 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -419,6 +419,9 @@ 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, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 60ff4573aa..8aeeb7df4f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. 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 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: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "sessionId": "thr_456", "thread": { "id": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 5563bd56e9..7cef88b14b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -3116,6 +3116,7 @@ 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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 5c4ba3a3e3..4a6644e30a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -101,7 +101,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ) .await??; let fork_result = fork_resp.result.clone(); - let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + let ThreadForkResponse { + session_id, thread, .. + } = to_response::(fork_resp)?; // Wire contract: thread title field is `name`, serialized as null when unset. let thread_json = fork_result @@ -121,6 +123,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.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider");