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.
This commit is contained in:
jif-oai
2026-05-06 12:04:27 +02:00
committed by GitHub
Unverified
parent fe24a180ab
commit 06e5dfa4dd
9 changed files with 30 additions and 4 deletions
+1
View File
@@ -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(),
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -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,
+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. 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": { } } }
```
@@ -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,
@@ -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::<ThreadForkResponse>(fork_resp)?;
let ThreadForkResponse {
session_id, thread, ..
} = to_response::<ThreadForkResponse>(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");