Restore collaboration mode when resuming threads (#45519)

## Why

Resuming a thread initialized its collaboration mode to Default, losing the saved Plan mode and its developer instructions. Reconnecting clients also lacked a server-reported mode to reconcile changes made by another client.

## What changed

- Restore the saved collaboration mode from the latest matching `ThreadSettingsApplied` event, falling back to the last legacy `TurnContext`. Apply the effective model and reasoning effort while retaining the saved mode and developer instructions.
- Include `collaborationMode` in `thread/resume` responses and update the generated schemas and bindings.
- Use the restored mode in the TUI, including the first prompt after resume. Prefer the server's mode when restoring disconnected input, while preserving the local selection for older servers that omit it.

## Testing

Add regression coverage for persisted and legacy collaboration modes, model and reasoning-effort overrides, the resumed Plan mode display and first prompt, and reconnect behavior with and without a server-reported mode.

GitOrigin-RevId: ed064516e7fae1c1668152ab448f510cbcacfe06
This commit is contained in:
Eric Traut
2026-09-14 21:33:45 +00:00
committed by copyberry
parent 7c73903be2
commit 91d54f1667
22 changed files with 309 additions and 13 deletions
@@ -372,6 +372,7 @@ fn sample_thread_resume_response_with_source(
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
collaboration_mode: None,
multi_agent_mode: Default::default(),
initial_turns_page: None,
turns_backwards_cursor: None,
+1
View File
@@ -659,6 +659,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload {
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
collaboration_mode: None,
multi_agent_mode: Default::default(),
initial_turns_page: None,
turns_backwards_cursor: None,
@@ -23344,6 +23344,17 @@
],
"description": "Reviewer currently used for approval requests on this thread."
},
"collaborationMode": {
"anyOf": [
{
"$ref": "#/definitions/v2/CollaborationMode"
},
{
"type": "null"
}
],
"description": "Effective collaboration mode. Absent when resuming from an older server."
},
"cwd": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -21058,6 +21058,17 @@
],
"description": "Reviewer currently used for approval requests on this thread."
},
"collaborationMode": {
"anyOf": [
{
"$ref": "#/definitions/CollaborationMode"
},
{
"type": "null"
}
],
"description": "Effective collaboration mode. Absent when resuming from an older server."
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -324,6 +324,22 @@
],
"type": "string"
},
"CollaborationMode": {
"description": "Collaboration mode for a Codex session.",
"properties": {
"mode": {
"$ref": "#/definitions/ModeKind"
},
"settings": {
"$ref": "#/definitions/Settings"
}
},
"required": [
"mode",
"settings"
],
"type": "object"
},
"CommandAction": {
"oneOf": [
{
@@ -908,6 +924,14 @@
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"MultiAgentMode": {
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
@@ -1158,6 +1182,34 @@
}
]
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
"developer_instructions": {
"type": [
"string",
"null"
]
},
"model": {
"type": "string"
},
"reasoning_effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
}
},
"required": [
"model"
],
"type": "object"
},
"SubAgentActivityKind": {
"enum": [
"started",
@@ -2965,6 +3017,17 @@
],
"description": "Reviewer currently used for approval requests on this thread."
},
"collaborationMode": {
"anyOf": [
{
"$ref": "#/definitions/CollaborationMode"
},
{
"type": "null"
}
],
"description": "Effective collaboration mode. Absent when resuming from an older server."
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { CollaborationMode } from "../CollaborationMode";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -23,6 +24,9 @@ approvalsReviewer: ApprovalsReviewer, /**
* should prefer `activePermissionProfile` for profile provenance.
*/
sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, /**
* Effective collaboration mode. Absent when resuming from an older server.
*/
collaborationMode: CollaborationMode | null, /**
* Opaque cursor for hydrating paginated turns backwards.
*
* Pass this as `cursor` to `thread/turns/list` with
@@ -327,6 +327,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
sandbox: SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
collaboration_mode: None,
multi_agent_mode: Default::default(),
initial_turns_page: Some(TurnsPage {
data: Vec::new(),
@@ -458,6 +458,8 @@ pub struct ThreadResumeResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// Effective collaboration mode. Absent when resuming from an older server.
pub collaboration_mode: Option<CollaborationMode>,
/// @deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior.
#[experimental("thread/resume.multiAgentMode")]
#[serde(default)]
@@ -732,6 +732,7 @@ pub(super) async fn handle_pending_thread_resume_request(
active_permission_profile,
workspace_roots,
reasoning_effort,
collaboration_mode,
originator,
..
} = config_snapshot;
@@ -755,6 +756,7 @@ pub(super) async fn handle_pending_thread_resume_request(
sandbox,
active_permission_profile,
reasoning_effort,
collaboration_mode: Some(collaboration_mode),
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
initial_turns_page,
turns_backwards_cursor,
@@ -4119,6 +4119,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
collaboration_mode: Some(config_snapshot.collaboration_mode),
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
initial_turns_page,
turns_backwards_cursor,
@@ -1448,11 +1448,11 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
thread_id: thread_id.clone(),
cwd: Some(persisted_cwd.clone()),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: None,
developer_instructions: None,
developer_instructions: Some("Persisted plan instructions".to_string()),
},
}),
..Default::default()
@@ -1481,21 +1481,105 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
.await?;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id,
thread_id: thread_id.clone(),
model: Some("gpt-5.4".to_string()),
..Default::default()
})
.await?;
let ThreadResumeResponse {
cwd,
reasoning_effort,
collaboration_mode,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert_eq!(reasoning_effort, None);
assert_eq!(reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(cwd.as_path(), persisted_cwd);
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
let mode = items.iter().rev().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
if event
.thread_id
.is_some_and(|id| id.to_string() == thread_id) =>
{
Some(event.thread_settings.collaboration_mode.clone())
}
_ => None,
});
assert_eq!(collaboration_mode, mode);
assert_eq!(
mode,
Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: Some("Persisted plan instructions".to_string())
},
})
);
Ok(())
}
#[tokio::test]
async fn thread_resume_restores_collaboration_mode_from_legacy_turn_context() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let fixture = setup_rollout_fixture(codex_home.path(), &server.uri()).await?;
mock_responses_config(&server.uri())
.with_root_config("model_reasoning_effort = \"high\"")
.write(codex_home.path())?;
let saved_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.2-codex".into(),
reasoning_effort: Some(ReasoningEffort::Low),
developer_instructions: Some("Legacy plan instructions".into()),
},
};
let context = serde_json::from_value(json!({
"cwd": codex_home.path(),
"approval_policy": "never",
"sandbox_policy": {"type": "read-only"},
"model": saved_mode.settings.model,
"effort": saved_mode.settings.reasoning_effort,
"summary": "auto",
"collaboration_mode": saved_mode,
}))?;
append_rollout_item_to_path(
&fixture.rollout_file_path,
&RolloutItem::TurnContext(context),
)
.await?;
let (items, _, _) = RolloutRecorder::load_rollout_items(&fixture.rollout_file_path).await?;
assert!(!items.iter().any(|item| matches!(
item,
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(_))
)));
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: fixture.conversation_id,
model: Some("gpt-5.4".into()),
..Default::default()
})
.await?;
let response: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert_eq!(
response.collaboration_mode,
Some(saved_mode.with_updates(
Some("gpt-5.4".into()),
Some(Some(ReasoningEffort::High)),
/*developer_instructions*/ None,
))
);
Ok(())
}
#[tokio::test]
async fn cold_resume_reresolves_persisted_active_permission_profile() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
+35
View File
@@ -761,6 +761,34 @@ impl Session {
});
// TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode
// to avoid extracting these fields separately and constructing CollaborationMode here.
let persisted_collaboration_mode = match &conversation_history {
InitialHistory::Resumed(resumed) => resumed
.history
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
if event.thread_id == Some(resumed.conversation_id) =>
{
Some(&event.thread_settings.collaboration_mode)
}
_ => None,
})
.or_else(|| {
resumed
.history
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(context) => {
Some(context.collaboration_mode.as_ref())
}
_ => None,
})
.flatten()
}),
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
};
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -769,6 +797,13 @@ impl Session {
developer_instructions: None,
},
};
let collaboration_mode = persisted_collaboration_mode.map_or(collaboration_mode, |saved| {
saved.with_updates(
Some(model.clone()),
Some(config.model_reasoning_effort.clone()),
/*developer_instructions*/ None,
)
});
let fast_mode_enabled = config.features.enabled(Feature::FastMode);
let initial_service_tier_warning = unsupported_service_tier_warning(
config.service_tier.as_deref(),
@@ -951,13 +951,14 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
.await;
let mut builder = test_codex();
let initial = builder.build(&server).await?;
let initial = builder.build_with_auto_env(&server).await?;
let collab_text = "resume instructions";
let mode = collab_mode_for_model(ModeKind::Plan, "gpt-5.5", Some(collab_text));
core_test_support::submit_thread_settings(
&initial.codex,
ThreadSettingsOverrides {
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
collaboration_mode: Some(mode),
..Default::default()
},
)
@@ -973,6 +974,15 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let resumed = builder.restart(&server, &initial).await?;
assert_eq!(
resumed
.codex
.config_snapshot()
.await
.collaboration_mode
.mode,
ModeKind::Plan
);
resumed
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
@@ -51,11 +51,20 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
app.chat_widget.set_collaboration_mask(
crate::collaboration_modes::plan_mask(app.model_catalog.as_ref()).unwrap(),
);
let expected_mode = app.chat_widget.effective_collaboration_mode().with_updates(
let cached_mode = app.chat_widget.effective_collaboration_mode().with_updates(
Some("gpt-test".into()),
Some(None),
/*developer_instructions*/ None,
);
// A newer server can report a mode changed by another client while disconnected.
let server_mode = (!recovered_queue).then(|| CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
developer_instructions: Some("Updated by another client".into()),
..cached_mode.settings.clone()
},
});
let expected_mode = server_mode.clone().unwrap_or(cached_mode);
let expected_submitted_mode = expected_mode.clone();
assert!(!app.model_catalog.collaboration_modes.is_empty());
if edit_offline {
@@ -118,8 +127,12 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
let params = request.params.as_ref().unwrap();
assert_eq!(params["threadId"], id.to_string());
assert!(params["model"].is_null());
Some(json!({"result": {"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": cwd,
"approvalPolicy": "never", "approvalsReviewer": "user", "sandbox": {"type": "dangerFullAccess"}, "reasoningEffort": null}}))
let mut result = json!({"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": cwd,
"approvalPolicy": "never", "approvalsReviewer": "user", "sandbox": {"type": "dangerFullAccess"}, "reasoningEffort": null});
if let Some(mode) = &server_mode {
result["collaborationMode"] = json!(mode);
}
Some(json!({"result": result}))
}
"thread/read" => Some(json!({"result": {"thread": thread}})),
"thread/list" | "thread/loaded/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
+9
View File
@@ -1814,6 +1814,10 @@ impl App {
replay_filter::snapshot_has_pending_interactive_request(&snapshot);
self.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
let has_resumed_collaboration_mode = snapshot
.session
.as_ref()
.is_some_and(|session| session.collaboration_mode.is_some());
if let Some(session) = snapshot.session {
if session.reasoning_effort != Some(ReasoningEffortConfig::Ultra) {
self.chat_widget
@@ -1867,7 +1871,12 @@ impl App {
.send(AppEvent::EndInitialHistoryReplayBuffer);
}
if recovered_input.is_some() {
let mode = has_resumed_collaboration_mode
.then(|| self.chat_widget.effective_collaboration_mode());
self.chat_widget.restore_reconnected_input(recovered_input);
if let Some(mode) = mode {
self.chat_widget.set_effective_collaboration_mode(mode);
}
}
self.restore_realtime_replay_state_after_replay(
&replayed_final_items,
+36 -2
View File
@@ -2342,7 +2342,7 @@ async fn thread_session_state_from_thread_resume_response(
thread_params_mode,
)
};
thread_session_state_from_thread_response(
let mut session = thread_session_state_from_thread_response(
&response.thread.id,
response.thread.forked_from_id.clone(),
response.thread.name.clone(),
@@ -2361,7 +2361,9 @@ async fn thread_session_state_from_thread_resume_response(
config.personality,
local_settings,
)
.await
.await?;
session.collaboration_mode = response.collaboration_mode.clone().map(Box::new);
Ok(session)
}
async fn thread_session_state_from_thread_fork_response(
@@ -3993,6 +3995,14 @@ mod tests {
.into(),
active_permission_profile: None,
reasoning_effort: None,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Plan,
settings: codex_protocol::config_types::Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: None,
developer_instructions: Some("Keep planning".to_string()),
},
}),
multi_agent_mode: Default::default(),
initial_turns_page: None,
turns_backwards_cursor: None,
@@ -4021,6 +4031,30 @@ mod tests {
assert_eq!(started.turns[0], response.thread.turns[0]);
assert!(!started.blocks_direct_input);
// The first prompt after resume must preserve the server's restored mode.
let (mut chat, _sender, _events, mut commands) =
crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await;
chat.handle_thread_session(started.session);
insta::assert_snapshot!(
"resumed_plan_mode",
crate::chatwidget::tests::helpers::normalize_snapshot_paths(
crate::chatwidget::tests::helpers::render_bottom_popup(&chat, /*width*/ 40),
)
);
chat.handle_paste("Continue planning".to_string());
chat.handle_key_event(crossterm::event::KeyCode::Enter.into());
let submitted_mode = std::iter::from_fn(|| commands.try_recv().ok()).find_map(|command| {
if let crate::app_command::AppCommand::UserTurn {
collaboration_mode, ..
} = command
{
Some(collaboration_mode)
} else {
None
}
});
assert_eq!(submitted_mode, Some(response.collaboration_mode.clone()));
let embedded_config = ConfigBuilder::default()
.codex_home(temp_dir.path().join("embedded-codex-home"))
.harness_overrides(ConfigOverrides {
+1 -1
View File
@@ -52,7 +52,7 @@ impl ChatWidget {
input.recovered_queue = true;
}
input.current_collaboration_mode = self.current_collaboration_mode.clone();
// Resume supplies model/effort, but not the user's selected collaboration mode.
// Keep the local selection for older servers. Replay reapplies a supplied mode.
if let Some(mask) = input.active_collaboration_mask.as_mut() {
mask.model = Some(self.current_model().to_string());
mask.reasoning_effort = Some(self.effective_reasoning_effort());
+1 -1
View File
@@ -524,7 +524,7 @@ impl ChatWidget {
.set_workspace_roots(self.config.workspace_roots.clone());
}
pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) {
pub(crate) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) {
let mode_kind = mode.mode;
let settings = mode.settings;
if mode_kind == ModeKind::Default {
@@ -0,0 +1,7 @@
---
source: tui/src/app_server_session.rs
expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&chat, 40)"
---
› Ask Codex to do anything
gpt-5.4 default · /tmp/pr… Plan mode
@@ -11849,6 +11849,13 @@ class ThreadResumeResponse(BaseModel):
description="Reviewer currently used for approval requests on this thread.",
),
]
collaboration_mode: Annotated[
CollaborationMode | None,
Field(
alias="collaborationMode",
description="Effective collaboration mode. Absent when resuming from an older server.",
),
] = None
cwd: AbsolutePathBuf
disabled_plugin_ids: Annotated[
list[str] | None,