mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:40:44 +00:00
Use model catalog collaboration mode messages (#36351)
## What changed - Add optional `default` and `plan` collaboration-mode messages to model catalog metadata. - Prefer the active mode's catalog message, including an explicit empty value, while falling back to the existing developer instructions when the catalog variant is absent. - Track the model in collaboration-mode world state so mode and model changes append the appropriate instructions, and clear prior instructions when no current message exists. - Preserve catalog collaboration messages when instruction overrides remove other model instruction fields. ## Testing - Cover catalog deserialization, variant selection, fallback and empty-message behavior, legacy snapshot migration, mode changes, and model changes. GitOrigin-RevId: d4c6c0028423bc50b0e8175e55586f3e7bc1c3a8
This commit is contained in:
@@ -3,36 +3,66 @@ use super::WorldStateSection;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::openai_models::CollaborationModeMessages;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Collaboration-mode instructions currently visible to the model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CollaborationModeState {
|
||||
mode: ModeKind,
|
||||
instructions: String,
|
||||
model: String,
|
||||
instructions: Option<String>,
|
||||
}
|
||||
|
||||
impl CollaborationModeState {
|
||||
pub(crate) fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
|
||||
collaboration_mode
|
||||
.settings
|
||||
.developer_instructions
|
||||
.clone()
|
||||
.filter(|instructions| !instructions.is_empty())
|
||||
.map(|instructions| Self {
|
||||
mode: collaboration_mode.mode,
|
||||
instructions,
|
||||
})
|
||||
pub(crate) fn from_collaboration_mode(
|
||||
collaboration_mode: &CollaborationMode,
|
||||
catalog_messages: Option<&CollaborationModeMessages>,
|
||||
) -> Self {
|
||||
let catalog_instructions =
|
||||
catalog_messages.and_then(|messages| match collaboration_mode.mode {
|
||||
ModeKind::Default => messages.default.as_ref(),
|
||||
ModeKind::Plan => messages.plan.as_ref(),
|
||||
ModeKind::PairProgramming | ModeKind::Execute => None,
|
||||
});
|
||||
|
||||
Self {
|
||||
mode: collaboration_mode.mode,
|
||||
model: collaboration_mode.settings.model.clone(),
|
||||
instructions: catalog_instructions.cloned().or_else(|| {
|
||||
collaboration_mode
|
||||
.settings
|
||||
.developer_instructions
|
||||
.clone()
|
||||
.filter(|instructions| !instructions.is_empty())
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum CollaborationModeSnapshot {
|
||||
Current { mode: ModeKind, model: String },
|
||||
Legacy(ModeKind),
|
||||
}
|
||||
|
||||
impl WorldStateSection for CollaborationModeState {
|
||||
const ID: &'static str = "collaboration_mode";
|
||||
type Snapshot = ModeKind;
|
||||
type Snapshot = CollaborationModeSnapshot;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
self.mode
|
||||
CollaborationModeSnapshot::Current {
|
||||
mode: self.mode,
|
||||
model: self.model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_persist(&self) -> bool {
|
||||
self.instructions.is_some()
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
|
||||
@@ -51,14 +81,18 @@ impl WorldStateSection for CollaborationModeState {
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
if matches!(previous, PreviousSectionState::Known(previous) if previous == &self.mode)
|
||||
|| matches!(previous, PreviousSectionState::Unknown)
|
||||
if matches!(
|
||||
previous,
|
||||
PreviousSectionState::Known(CollaborationModeSnapshot::Current { mode, model })
|
||||
if *mode == self.mode && model == &self.model
|
||||
) || matches!(previous, PreviousSectionState::Unknown)
|
||||
|| (self.instructions.is_none() && matches!(previous, PreviousSectionState::Absent))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Box::new(CollaborationModeInstructions {
|
||||
instructions: self.instructions.clone(),
|
||||
instructions: self.instructions.clone().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use crate::context::world_state::WorldState;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::CollaborationModeMessages;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn snapshots() {
|
||||
@@ -31,7 +33,7 @@ fn snapshots() {
|
||||
fn persisted_instructions_are_restored_only_when_missing_from_history() {
|
||||
let state = collaboration_mode_state(ModeKind::Default, "pair with the user");
|
||||
let retained: ResponseItem = ContextualUserFragment::into(CollaborationModeInstructions {
|
||||
instructions: state.instructions.clone(),
|
||||
instructions: state.instructions.clone().expect("test instructions"),
|
||||
});
|
||||
let mut world_state = WorldState::default();
|
||||
world_state.add_section(state);
|
||||
@@ -53,14 +55,107 @@ fn persisted_instructions_are_restored_only_when_missing_from_history() {
|
||||
);
|
||||
}
|
||||
|
||||
fn collaboration_mode_state(mode: ModeKind, instructions: &str) -> CollaborationModeState {
|
||||
CollaborationModeState::from_collaboration_mode(&CollaborationMode {
|
||||
#[test]
|
||||
fn catalog_collaboration_messages_select_mode_variant() {
|
||||
let messages = CollaborationModeMessages {
|
||||
default: Some("catalog default instructions".to_string()),
|
||||
plan: Some("catalog plan instructions".to_string()),
|
||||
};
|
||||
|
||||
for (mode, expected) in [
|
||||
(ModeKind::Default, "catalog default instructions"),
|
||||
(ModeKind::Plan, "catalog plan instructions"),
|
||||
] {
|
||||
let state = CollaborationModeState::from_collaboration_mode(
|
||||
&collaboration_mode(mode, Some("legacy instructions")),
|
||||
Some(&messages),
|
||||
);
|
||||
|
||||
assert_eq!(state.instructions.as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_collaboration_message_suppresses_legacy_instructions() {
|
||||
let messages = CollaborationModeMessages {
|
||||
default: None,
|
||||
plan: Some(String::new()),
|
||||
};
|
||||
let state = CollaborationModeState::from_collaboration_mode(
|
||||
&collaboration_mode(ModeKind::Plan, Some("legacy plan instructions")),
|
||||
Some(&messages),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.expect("explicit empty collaboration message")
|
||||
.render(),
|
||||
format!("{COLLABORATION_MODE_OPEN_TAG}{COLLABORATION_MODE_CLOSE_TAG}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_catalog_collaboration_message_uses_legacy_instructions() {
|
||||
let messages = CollaborationModeMessages {
|
||||
default: Some("catalog default instructions".to_string()),
|
||||
plan: None,
|
||||
};
|
||||
let state = CollaborationModeState::from_collaboration_mode(
|
||||
&collaboration_mode(ModeKind::Plan, Some("legacy plan instructions")),
|
||||
Some(&messages),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.instructions.as_deref(),
|
||||
Some("legacy plan instructions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_collaboration_mode_snapshots_refresh_catalog_messages_once() {
|
||||
let previous = serde_json::from_str::<CollaborationModeSnapshot>("\"default\"")
|
||||
.expect("legacy collaboration mode snapshot");
|
||||
|
||||
for instructions in ["catalog instructions", ""] {
|
||||
let messages = CollaborationModeMessages {
|
||||
default: Some(instructions.to_string()),
|
||||
plan: None,
|
||||
};
|
||||
let state = CollaborationModeState::from_collaboration_mode(
|
||||
&collaboration_mode(ModeKind::Default, Some("stale legacy instructions")),
|
||||
Some(&messages),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.render_diff(PreviousSectionState::Known(&previous))
|
||||
.expect("legacy snapshot should refresh collaboration instructions")
|
||||
.render(),
|
||||
format!("{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}")
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.render_diff(PreviousSectionState::Known(&state.snapshot()))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn collaboration_mode(mode: ModeKind, instructions: Option<&str>) -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode,
|
||||
settings: Settings {
|
||||
model: "test-model".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some(instructions.to_string()),
|
||||
developer_instructions: instructions.map(str::to_string),
|
||||
},
|
||||
})
|
||||
.expect("test collaboration mode should have instructions")
|
||||
}
|
||||
}
|
||||
|
||||
fn collaboration_mode_state(mode: ModeKind, instructions: &str) -> CollaborationModeState {
|
||||
CollaborationModeState::from_collaboration_mode(
|
||||
&collaboration_mode(mode, Some(instructions)),
|
||||
/*catalog_messages*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-5
@@ -5,17 +5,17 @@ expression: "render_section_cases(&[(Absent, Absent), (Absent, Known(&default)),
|
||||
Absent -> Absent
|
||||
None
|
||||
|
||||
Absent -> "default" (role - developer)
|
||||
Absent -> {"mode":"default","model":"test-model"} (role - developer)
|
||||
<collaboration_mode>pair with the user</collaboration_mode>
|
||||
|
||||
"default" -> "default"
|
||||
{"mode":"default","model":"test-model"} -> {"mode":"default","model":"test-model"}
|
||||
None
|
||||
|
||||
"default" -> "default"
|
||||
{"mode":"default","model":"test-model"} -> {"mode":"default","model":"test-model"}
|
||||
None
|
||||
|
||||
"default" -> "plan" (role - developer)
|
||||
{"mode":"default","model":"test-model"} -> {"mode":"plan","model":"test-model"} (role - developer)
|
||||
<collaboration_mode>make a plan</collaboration_mode>
|
||||
|
||||
Unknown -> "default"
|
||||
Unknown -> {"mode":"default","model":"test-model"}
|
||||
None
|
||||
|
||||
@@ -1453,6 +1453,7 @@ mod tests {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: Some(AutoReviewMessages {
|
||||
policy: Some("Use the catalog Guardian policy.".to_string()),
|
||||
policy_template: Some(catalog_template.to_string()),
|
||||
@@ -1486,6 +1487,7 @@ mod tests {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: Some(AutoReviewMessages {
|
||||
policy: Some(String::new()),
|
||||
policy_template: None,
|
||||
@@ -1527,6 +1529,7 @@ mod tests {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: Some(AutoReviewMessages {
|
||||
policy: Some(catalog_policy.to_string()),
|
||||
policy_template: Some(String::new()),
|
||||
|
||||
@@ -129,11 +129,15 @@ impl Session {
|
||||
.enabled(Feature::RequestPermissionsTool),
|
||||
));
|
||||
}
|
||||
if turn_context.config.include_collaboration_mode_instructions
|
||||
&& let Some(collaboration_mode) =
|
||||
CollaborationModeState::from_collaboration_mode(&turn_context.collaboration_mode())
|
||||
{
|
||||
world_state.add_section(collaboration_mode);
|
||||
if turn_context.config.include_collaboration_mode_instructions {
|
||||
world_state.add_section(CollaborationModeState::from_collaboration_mode(
|
||||
&turn_context.collaboration_mode(),
|
||||
turn_context
|
||||
.model_info
|
||||
.model_messages
|
||||
.as_ref()
|
||||
.and_then(|messages| messages.collaboration_modes.as_ref()),
|
||||
));
|
||||
}
|
||||
if turn_context.config.include_environment_context {
|
||||
let current_date = self
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -66,6 +67,7 @@ use crate::responses::output_value_to_text;
|
||||
use crate::responses::start_mock_server;
|
||||
use crate::streaming_sse::StreamingSseServer;
|
||||
use crate::test_environment;
|
||||
use crate::wait_for_event;
|
||||
use crate::wait_for_event_match;
|
||||
use crate::wait_for_event_with_timeout;
|
||||
use wiremock::Match;
|
||||
@@ -831,6 +833,25 @@ impl TestCodex {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Submits a text turn without changing the current thread settings.
|
||||
pub async fn submit_text_turn(&self, prompt: &str) -> Result<()> {
|
||||
self.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: ThreadSettingsOverrides::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&self.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn submit_turn_with_permission_profile(
|
||||
&self,
|
||||
prompt: &str,
|
||||
@@ -960,7 +981,7 @@ impl TestCodex {
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
thread_settings: ThreadSettingsOverrides {
|
||||
environments: turn_environment_selections,
|
||||
approval_policy: Some(approval_policy),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
|
||||
@@ -34,6 +34,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: Some(PermissionMessages {
|
||||
danger_full_access: None,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use anyhow::Result;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::CollaborationModeMessages;
|
||||
use codex_protocol::openai_models::ModelMessages;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -37,6 +41,43 @@ fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMod
|
||||
collab_mode_with_mode_and_instructions(ModeKind::Default, instructions)
|
||||
}
|
||||
|
||||
fn collab_mode_for_model(
|
||||
mode: ModeKind,
|
||||
model: &str,
|
||||
instructions: Option<&str>,
|
||||
) -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode,
|
||||
settings: Settings {
|
||||
model: model.to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: instructions.map(str::to_string),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn model_with_collaboration_messages(
|
||||
slug: &str,
|
||||
default: Option<&str>,
|
||||
plan: Option<&str>,
|
||||
) -> codex_protocol::openai_models::ModelInfo {
|
||||
let mut model = model_info_from_slug(slug);
|
||||
let model_messages = model.model_messages.get_or_insert(ModelMessages {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
});
|
||||
model_messages.collaboration_modes = Some(CollaborationModeMessages {
|
||||
default: default.map(str::to_string),
|
||||
plan: plan.map(str::to_string),
|
||||
});
|
||||
model
|
||||
}
|
||||
|
||||
fn developer_texts(input: &[Value]) -> Vec<String> {
|
||||
input
|
||||
.iter()
|
||||
@@ -101,6 +142,227 @@ async fn no_collaboration_instructions_by_default() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn catalog_collaboration_messages_track_mode_changes() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req1 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let req2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let model_slug = "catalog-collaboration-model";
|
||||
let default_text = "catalog default instructions";
|
||||
let plan_text = "catalog plan instructions";
|
||||
let model = model_with_collaboration_messages(model_slug, Some(default_text), Some(plan_text));
|
||||
let mut builder = test_codex()
|
||||
.with_model(model_slug)
|
||||
.with_config(move |config| {
|
||||
config.model_catalog = Some(ModelsResponse {
|
||||
models: vec![model],
|
||||
});
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
collaboration_mode: Some(collab_mode_for_model(
|
||||
ModeKind::Default,
|
||||
model_slug,
|
||||
Some("legacy default instructions"),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("default turn").await?;
|
||||
|
||||
let first_dev_texts = developer_texts(&req1.single_request().input());
|
||||
assert_eq!(
|
||||
count_messages_containing(&first_dev_texts, &collab_xml(default_text)),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
count_messages_containing(&first_dev_texts, "legacy default instructions"),
|
||||
0
|
||||
);
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
collaboration_mode: Some(collab_mode_for_model(
|
||||
ModeKind::Plan,
|
||||
model_slug,
|
||||
Some("legacy plan instructions"),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("plan turn").await?;
|
||||
|
||||
let second_dev_texts = developer_texts(&req2.single_request().input());
|
||||
assert_eq!(
|
||||
count_messages_containing(&second_dev_texts, &collab_xml(default_text)),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
count_messages_containing(&second_dev_texts, &collab_xml(plan_text)),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
count_messages_containing(&second_dev_texts, "legacy plan instructions"),
|
||||
0
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn missing_catalog_and_legacy_collaboration_message_clears_prior_instructions() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let req2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let model_slug = "catalog-collaboration-clear-model";
|
||||
let default_text = "catalog default instructions";
|
||||
let model =
|
||||
model_with_collaboration_messages(model_slug, Some(default_text), /*plan*/ None);
|
||||
let mut builder = test_codex()
|
||||
.with_model(model_slug)
|
||||
.with_config(move |config| {
|
||||
config.model_catalog = Some(ModelsResponse {
|
||||
models: vec![model],
|
||||
});
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
collaboration_mode: Some(collab_mode_for_model(
|
||||
ModeKind::Default,
|
||||
model_slug,
|
||||
/*instructions*/ None,
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("default turn").await?;
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
collaboration_mode: Some(collab_mode_for_model(
|
||||
ModeKind::Plan,
|
||||
model_slug,
|
||||
/*instructions*/ None,
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("plan turn").await?;
|
||||
|
||||
let dev_texts = developer_texts(&req2.single_request().input());
|
||||
assert_eq!(
|
||||
count_messages_containing(&dev_texts, &collab_xml(default_text)),
|
||||
1
|
||||
);
|
||||
assert_eq!(count_messages_containing(&dev_texts, &collab_xml("")), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_change_appends_new_catalog_collaboration_message() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let req2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let first_slug = "catalog-collaboration-model-a";
|
||||
let second_slug = "catalog-collaboration-model-b";
|
||||
let first_text = "model A collaboration instructions";
|
||||
let second_text = "model B collaboration instructions";
|
||||
let first = model_with_collaboration_messages(first_slug, Some(first_text), /*plan*/ None);
|
||||
let second =
|
||||
model_with_collaboration_messages(second_slug, Some(second_text), /*plan*/ None);
|
||||
let mut builder = test_codex()
|
||||
.with_model(first_slug)
|
||||
.with_config(move |config| {
|
||||
config.model_catalog = Some(ModelsResponse {
|
||||
models: vec![first, second],
|
||||
});
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
collaboration_mode: Some(collab_mode_for_model(
|
||||
ModeKind::Default,
|
||||
first_slug,
|
||||
Some("legacy instructions"),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("first").await?;
|
||||
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
model: Some(second_slug.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("second").await?;
|
||||
|
||||
let dev_texts = developer_texts(&req2.single_request().input());
|
||||
assert_eq!(
|
||||
count_messages_containing(&dev_texts, &collab_xml(first_text)),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
count_messages_containing(&dev_texts, &collab_xml(second_text)),
|
||||
1
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_input_includes_collaboration_instructions_after_override() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -54,6 +54,7 @@ fn model_with_approval_messages(
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
}),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -70,6 +71,7 @@ fn model_with_permission_messages(
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: Some(permissions),
|
||||
token_budget: None,
|
||||
@@ -223,6 +225,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: Some(approvals),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
|
||||
@@ -640,6 +640,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
personality_pragmatic: Some("Pragmatic variant".to_string()),
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -760,6 +761,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
personality_pragmatic: Some(remote_pragmatic_message.to_string()),
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
|
||||
@@ -113,6 +113,7 @@ fn clear_instruction_messages(model: &mut ModelInfo) {
|
||||
model_messages.instructions_template = None;
|
||||
model_messages.instructions_variables = None;
|
||||
if model_messages.approvals.is_none()
|
||||
&& model_messages.collaboration_modes.is_none()
|
||||
&& model_messages.auto_review.is_none()
|
||||
&& model_messages.permissions.is_none()
|
||||
&& model_messages.token_budget.is_none()
|
||||
@@ -180,6 +181,7 @@ fn local_personality_messages_for_slug(slug: &str) -> Option<ModelMessages> {
|
||||
personality_pragmatic: Some(LOCAL_PRAGMATIC_TEMPLATE.to_string()),
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::ModelsManagerConfig;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::openai_models::ApprovalMessages;
|
||||
use codex_protocol::openai_models::AutoReviewMessages;
|
||||
use codex_protocol::openai_models::CollaborationModeMessages;
|
||||
use codex_protocol::openai_models::PermissionMessages;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -31,6 +32,7 @@ fn base_instruction_override_preserves_catalog_approval_messages() {
|
||||
personality_pragmatic: Some("pragmatic".to_string()),
|
||||
}),
|
||||
approvals: Some(approvals.clone()),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -48,6 +50,7 @@ fn base_instruction_override_preserves_catalog_approval_messages() {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: Some(approvals),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -55,6 +58,38 @@ fn base_instruction_override_preserves_catalog_approval_messages() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_instruction_override_preserves_catalog_collaboration_mode_messages() {
|
||||
let mut model = model_info_from_slug("gpt-5.2-codex");
|
||||
let collaboration_modes = CollaborationModeMessages {
|
||||
default: Some("default instructions".to_string()),
|
||||
plan: Some("plan instructions".to_string()),
|
||||
};
|
||||
if let Some(model_messages) = model.model_messages.as_mut() {
|
||||
model_messages.collaboration_modes = Some(collaboration_modes.clone());
|
||||
}
|
||||
let config = ModelsManagerConfig {
|
||||
base_instructions: Some("override".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let updated = with_config_overrides(model, &config);
|
||||
|
||||
assert_eq!(
|
||||
updated.model_messages,
|
||||
Some(ModelMessages {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: Some(collaboration_modes),
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(updated.base_instructions, "override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_personality_preserves_catalog_approval_messages() {
|
||||
let mut model = model_info_from_slug("unknown-model");
|
||||
@@ -68,6 +103,7 @@ fn disabled_personality_preserves_catalog_approval_messages() {
|
||||
instructions_template: Some("template".to_string()),
|
||||
instructions_variables: None,
|
||||
approvals: Some(approvals.clone()),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -85,6 +121,7 @@ fn disabled_personality_preserves_catalog_approval_messages() {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: Some(approvals),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -103,6 +140,7 @@ fn base_instruction_override_preserves_catalog_auto_review_messages() {
|
||||
instructions_template: Some("template".to_string()),
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: Some(auto_review.clone()),
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -120,6 +158,7 @@ fn base_instruction_override_preserves_catalog_auto_review_messages() {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: Some(auto_review),
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -139,6 +178,7 @@ fn base_instruction_override_preserves_catalog_permission_messages() {
|
||||
instructions_template: Some("template".to_string()),
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: Some(permissions.clone()),
|
||||
token_budget: None,
|
||||
@@ -156,6 +196,7 @@ fn base_instruction_override_preserves_catalog_permission_messages() {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: Some(permissions),
|
||||
token_budget: None,
|
||||
@@ -193,6 +234,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
|
||||
instructions_template: Some(instructions.to_string()),
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
|
||||
@@ -507,6 +507,7 @@ pub struct ModelMessages {
|
||||
pub instructions_template: Option<String>,
|
||||
pub instructions_variables: Option<ModelInstructionsVariables>,
|
||||
pub approvals: Option<ApprovalMessages>,
|
||||
pub collaboration_modes: Option<CollaborationModeMessages>,
|
||||
pub auto_review: Option<AutoReviewMessages>,
|
||||
pub permissions: Option<PermissionMessages>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -531,6 +532,12 @@ pub struct ApprovalMessages {
|
||||
pub unless_trusted: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct CollaborationModeMessages {
|
||||
pub default: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct AutoReviewMessages {
|
||||
pub policy: Option<String>,
|
||||
@@ -763,13 +770,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_messages_deserialize_without_approvals() {
|
||||
fn model_messages_deserialize_without_optional_sections() {
|
||||
let messages: ModelMessages =
|
||||
from_str(r#"{"instructions_template":null,"instructions_variables":null}"#)
|
||||
.expect("model messages should deserialize");
|
||||
|
||||
assert_eq!(messages.approvals, None);
|
||||
assert_eq!(messages.permissions, None);
|
||||
assert_eq!(
|
||||
messages,
|
||||
ModelMessages {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -860,6 +877,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collaboration_mode_messages_preserve_missing_and_empty_values() {
|
||||
let messages: ModelMessages = from_str(
|
||||
r#"{
|
||||
"instructions_template": null,
|
||||
"instructions_variables": null,
|
||||
"collaboration_modes": {
|
||||
"default": ""
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("collaboration mode messages should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
messages,
|
||||
ModelMessages {
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
approvals: None,
|
||||
collaboration_modes: Some(CollaborationModeMessages {
|
||||
default: Some(String::new()),
|
||||
plan: None,
|
||||
}),
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_accepts_known_and_custom_values() {
|
||||
let custom = ReasoningEffort::Custom("future".to_string());
|
||||
@@ -932,6 +979,7 @@ mod tests {
|
||||
instructions_template: Some("Hello {{ personality }}".to_string()),
|
||||
instructions_variables: Some(personality_variables()),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -952,6 +1000,7 @@ mod tests {
|
||||
personality_pragmatic: None,
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -981,6 +1030,7 @@ mod tests {
|
||||
personality_pragmatic: None,
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
@@ -1013,6 +1063,7 @@ mod tests {
|
||||
personality_pragmatic: None,
|
||||
}),
|
||||
approvals: None,
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
permissions: None,
|
||||
token_budget: None,
|
||||
|
||||
Reference in New Issue
Block a user