Add model fallback for collaboration-mode instructions

This commit is contained in:
Ahmed Ibrahim
2026-01-29 17:46:26 -08:00
parent 81a17bb2c1
commit 0666e95234
7 changed files with 181 additions and 15 deletions
+19 -6
View File
@@ -1303,6 +1303,7 @@ impl Session {
fn build_collaboration_mode_update_item(
&self,
current_context: &TurnContext,
previous_collaboration_mode: &CollaborationMode,
next_collaboration_mode: Option<&CollaborationMode>,
) -> Option<ResponseItem> {
@@ -1312,7 +1313,8 @@ impl Session {
}
// If the next mode has empty developer instructions, this returns None and we emit no
// update, so prior collaboration instructions remain in the prompt history.
Some(DeveloperInstructions::from_collaboration_mode(next_mode)?.into())
let model_info = current_context.client.get_model_info();
Some(DeveloperInstructions::from_collaboration_mode(next_mode, &model_info)?.into())
} else {
None
}
@@ -1337,6 +1339,7 @@ impl Session {
update_items.push(permissions_item);
}
if let Some(collaboration_mode_item) = self.build_collaboration_mode_update_item(
current_context,
previous_collaboration_mode,
next_collaboration_mode,
) {
@@ -1838,13 +1841,23 @@ impl Session {
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
}
// Add developer instructions from collaboration_mode if they exist and are non-empty
let collaboration_mode = {
let session_configuration = {
let state = self.state.lock().await;
state.session_configuration.collaboration_mode.clone()
state.session_configuration.clone()
};
if let Some(collab_instructions) =
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
{
let per_turn_config = Self::build_per_turn_config(&session_configuration);
let model_info = self
.services
.models_manager
.get_model_info(
session_configuration.collaboration_mode.model(),
&per_turn_config,
)
.await;
if let Some(collab_instructions) = DeveloperInstructions::from_collaboration_mode(
&session_configuration.collaboration_mode,
&model_info,
) {
items.push(collab_instructions.into());
}
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
@@ -1,8 +1,10 @@
use std::collections::BTreeMap;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::CollaborationModesMessages;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelInstructionsTemplate;
@@ -114,6 +116,19 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
supports_reasoning_summaries: true,
context_window: Some(200_000),
)
} else if slug.starts_with("test-collab-template") {
model_info!(
slug,
base_instructions: BASE_INSTRUCTIONS_WITH_APPLY_PATCH.to_string(),
model_instructions_template: Some(ModelInstructionsTemplate {
template: "template".to_string(),
personality_messages: None,
collaboration_modes_messages: Some(CollaborationModesMessages(BTreeMap::from([(
ModeKind::Custom,
"model fallback".to_string(),
)]))),
}),
)
} else if slug.starts_with("codex-mini-latest") {
model_info!(
slug,
@@ -178,6 +193,7 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
Personality::Pragmatic,
PERSONALITY_PRAGMATIC.to_string(),
)]))),
collaboration_modes_messages: None,
}),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
@@ -222,6 +238,7 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
Personality::Pragmatic,
PERSONALITY_PRAGMATIC.to_string(),
)]))),
collaboration_modes_messages: None,
}),
)
} else if slug.starts_with("gpt-5.1-codex-max") {
@@ -22,11 +22,18 @@ fn sse_completed(id: &str) -> String {
sse(vec![ev_response_created(id), ev_completed(id)])
}
const TEST_COLLAB_MODEL: &str = "test-collab-template";
const MODEL_FALLBACK_TEXT: &str = "model fallback";
fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode {
collab_mode_with_model("gpt-5.1", instructions)
}
fn collab_mode_with_model(model: &str, instructions: Option<&str>) -> CollaborationMode {
CollaborationMode {
mode: ModeKind::Custom,
settings: Settings {
model: "gpt-5.1".to_string(),
model: model.to_string(),
reasoning_effort: None,
developer_instructions: instructions.map(str::to_string),
},
@@ -527,3 +534,106 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collaboration_instructions_precedence_mode_overrides_model_template() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex().with_model(TEST_COLLAB_MODEL);
let test = builder.build(&server).await?;
let mode_text = "mode instructions";
let collaboration_mode = collab_mode_with_model(TEST_COLLAB_MODEL, Some(mode_text));
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let mode_text = collab_xml(mode_text);
assert_eq!(count_exact(&dev_texts, &mode_text), 1);
let last_collab = dev_texts
.iter()
.rev()
.find(|text| text.starts_with(COLLABORATION_MODE_OPEN_TAG))
.cloned();
assert_eq!(last_collab, Some(mode_text));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collaboration_instructions_fall_back_to_model_template_when_mode_empty() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex().with_model(TEST_COLLAB_MODEL);
let test = builder.build(&server).await?;
let collaboration_mode = collab_mode_with_model(TEST_COLLAB_MODEL, None);
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = req.single_request();
let model = request
.body_json()
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
assert_eq!(model, TEST_COLLAB_MODEL.to_string());
let input = request.input();
let dev_texts = developer_texts(&input);
let model_text = collab_xml(MODEL_FALLBACK_TEXT);
assert_eq!(count_exact(&dev_texts, &model_text), 1);
Ok(())
}
+1
View File
@@ -296,6 +296,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
Personality::Friendly,
remote_personality_message.to_string(),
)]))),
collaboration_modes_messages: None,
}),
supports_reasoning_summaries: false,
support_verbosity: false,
+3 -1
View File
@@ -166,7 +166,9 @@ pub enum AltScreenMode {
}
/// Initial collaboration mode to use when the TUI starts.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, TS)]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, JsonSchema, TS,
)]
#[serde(rename_all = "snake_case")]
pub enum ModeKind {
Plan,
+21 -7
View File
@@ -12,6 +12,7 @@ use ts_rs::TS;
use crate::config_types::CollaborationMode;
use crate::config_types::SandboxMode;
use crate::openai_models::ModelInfo;
use crate::protocol::AskForApproval;
use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
@@ -301,17 +302,30 @@ impl DeveloperInstructions {
}
/// Returns developer instructions from a collaboration mode if they exist and are non-empty.
pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
collaboration_mode
/// Precedence:
/// 1. collaboration_mode.settings.developer_instructions if it exists
/// 2. model.collaboration_modes_messages for the collaboration_mode.mode if it exists
/// 3. None
pub fn from_collaboration_mode(
collaboration_mode: &CollaborationMode,
model: &ModelInfo,
) -> Option<Self> {
let instructions = collaboration_mode
.settings
.developer_instructions
.as_ref()
.filter(|instructions| !instructions.is_empty())
.map(|instructions| {
DeveloperInstructions::new(format!(
"{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}"
))
})
.or_else(|| {
model
.model_instructions_template
.as_ref()
.and_then(|template| template.collaboration_modes_messages.as_ref())
.and_then(|messages| messages.0.get(&collaboration_mode.mode))
})?;
Some(DeveloperInstructions::new(format!(
"{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}"
)))
}
fn from_permissions_with_network(
+9
View File
@@ -11,6 +11,7 @@ use strum_macros::EnumIter;
use tracing::warn;
use ts_rs::TS;
use crate::config_types::ModeKind;
use crate::config_types::Personality;
use crate::config_types::Verbosity;
@@ -252,6 +253,7 @@ impl ModelInfo {
pub struct ModelInstructionsTemplate {
pub template: String,
pub personality_messages: Option<PersonalityMessages>,
pub collaboration_modes_messages: Option<CollaborationModesMessages>,
}
impl ModelInstructionsTemplate {
@@ -272,6 +274,11 @@ impl ModelInstructionsTemplate {
#[serde(transparent)]
pub struct PersonalityMessages(pub BTreeMap<Personality, String>);
// serializes as a dictionary from collaboration mode to message
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
#[serde(transparent)]
pub struct CollaborationModesMessages(pub BTreeMap<ModeKind, String>);
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelInfoUpgrade {
pub model: String,
@@ -446,6 +453,7 @@ mod tests {
let model = test_model(Some(ModelInstructionsTemplate {
template: "Hello {{ personality_message }}".to_string(),
personality_messages: Some(personality_messages()),
collaboration_modes_messages: None,
}));
let instructions = model.get_model_instructions(Some(Personality::Friendly));
@@ -458,6 +466,7 @@ mod tests {
let model = test_model(Some(ModelInstructionsTemplate {
template: "Hello there".to_string(),
personality_messages: Some(personality_messages()),
collaboration_modes_messages: None,
}));
let instructions = model.get_model_instructions(Some(Personality::Friendly));