mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
feat: support realtime prompt overrides
This commit is contained in:
@@ -3011,6 +3011,8 @@ mod tests {
|
||||
model: Some("realtime-treatment-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("You are on a call".to_string())),
|
||||
realtime_start_instructions: Some("Realtime started".to_string()),
|
||||
realtime_end_instructions: Some("Realtime ended".to_string()),
|
||||
realtime_session_id: Some("sess_456".to_string()),
|
||||
transport: None,
|
||||
version: Some(RealtimeConversationVersion::V1),
|
||||
@@ -3026,6 +3028,8 @@ mod tests {
|
||||
"model": "realtime-treatment-model",
|
||||
"outputModality": "audio",
|
||||
"prompt": "You are on a call",
|
||||
"realtimeStartInstructions": "Realtime started",
|
||||
"realtimeEndInstructions": "Realtime ended",
|
||||
"realtimeSessionId": "sess_456",
|
||||
"transport": null,
|
||||
"version": "v1",
|
||||
@@ -3046,6 +3050,8 @@ mod tests {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: None,
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3060,6 +3066,8 @@ mod tests {
|
||||
"threadId": "thr_123",
|
||||
"model": null,
|
||||
"outputModality": "audio",
|
||||
"realtimeStartInstructions": null,
|
||||
"realtimeEndInstructions": null,
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"version": null,
|
||||
@@ -3076,6 +3084,8 @@ mod tests {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(None),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3091,6 +3101,8 @@ mod tests {
|
||||
"model": null,
|
||||
"outputModality": "audio",
|
||||
"prompt": null,
|
||||
"realtimeStartInstructions": null,
|
||||
"realtimeEndInstructions": null,
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"version": null,
|
||||
@@ -3106,6 +3118,8 @@ mod tests {
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"outputModality": "audio",
|
||||
"realtimeStartInstructions": null,
|
||||
"realtimeEndInstructions": null,
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"voice": null
|
||||
@@ -3123,6 +3137,8 @@ mod tests {
|
||||
"threadId": "thr_123",
|
||||
"outputModality": "audio",
|
||||
"prompt": null,
|
||||
"realtimeStartInstructions": null,
|
||||
"realtimeEndInstructions": null,
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"voice": null
|
||||
@@ -3249,6 +3265,8 @@ mod tests {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("You are on a call".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
|
||||
@@ -80,6 +80,10 @@ pub struct ThreadRealtimeStartParams {
|
||||
#[ts(optional = nullable)]
|
||||
pub prompt: Option<Option<String>>,
|
||||
#[ts(optional = nullable)]
|
||||
pub realtime_start_instructions: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub realtime_end_instructions: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub realtime_session_id: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub transport: Option<ThreadRealtimeStartTransport>,
|
||||
|
||||
@@ -831,13 +831,15 @@ const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
```
|
||||
|
||||
Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend_prompt` for the backend instructions and the thread conversation id as the default Realtime API session identifier. This `realtimeSessionId` value refers to the upstream Realtime API session, not a Codex session/thread-group id. The start response is `{}`; the remote answer SDP arrives later as `thread/realtime/sdp` and should be passed to `setRemoteDescription()`:
|
||||
Then send `offer.sdp` to app-server. `prompt`, `realtimeStartInstructions`, and `realtimeEndInstructions` override the built-in realtime backend, start, and end prompts for this session. The local `experimental_realtime_ws_backend_prompt`, `experimental_realtime_start_instructions`, and `experimental_realtime_end_instructions` config values take precedence when set. Core uses the thread conversation id as the default Realtime API session identifier. This `realtimeSessionId` value refers to the upstream Realtime API session, not a Codex session/thread-group id. The start response is `{}`; the remote answer SDP arrives later as `thread/realtime/sdp` and should be passed to `setRemoteDescription()`:
|
||||
|
||||
```json
|
||||
{ "method": "thread/realtime/start", "id": 40, "params": {
|
||||
"threadId": "thr_123",
|
||||
"outputModality": "audio",
|
||||
"prompt": "You are on a call.",
|
||||
"realtimeStartInstructions": "Realtime conversation started.",
|
||||
"realtimeEndInstructions": "Realtime conversation ended.",
|
||||
"realtimeSessionId": null,
|
||||
"transport": { "type": "webrtc", "sdp": "v=0\r\no=..." }
|
||||
} }
|
||||
|
||||
@@ -937,6 +937,8 @@ impl TurnRequestProcessor {
|
||||
model: params.model,
|
||||
output_modality: params.output_modality,
|
||||
prompt: params.prompt,
|
||||
realtime_start_instructions: params.realtime_start_instructions,
|
||||
realtime_end_instructions: params.realtime_end_instructions,
|
||||
realtime_session_id: params.realtime_session_id,
|
||||
transport: params.transport.map(|transport| match transport {
|
||||
ThreadRealtimeStartTransport::Websocket => {
|
||||
|
||||
@@ -83,6 +83,8 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("hello".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -191,6 +193,8 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("hello".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
|
||||
@@ -317,6 +317,8 @@ impl RealtimeE2eHarness {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: offer_sdp.to_string(),
|
||||
@@ -564,6 +566,8 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
model: Some("realtime-treatment-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: None,
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -819,6 +823,8 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Text,
|
||||
prompt: None,
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -995,6 +1001,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1094,6 +1102,8 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
@@ -2141,6 +2151,8 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
@@ -2202,6 +2214,8 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
|
||||
@@ -391,6 +391,10 @@ pub struct ConfigToml {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
/// Experimental / do not use. Replaces the built-in realtime end
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// inactive.
|
||||
pub experimental_realtime_end_instructions: Option<String>,
|
||||
|
||||
/// Experimental / do not use. When set, app-server fetches thread-scoped
|
||||
/// config from a remote service at this endpoint.
|
||||
|
||||
@@ -4478,6 +4478,10 @@
|
||||
"experimental_compact_prompt_file": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"experimental_realtime_end_instructions": {
|
||||
"description": "Experimental / do not use. Replaces the built-in realtime end instructions inserted into developer messages when realtime becomes inactive.",
|
||||
"type": "string"
|
||||
},
|
||||
"experimental_realtime_start_instructions": {
|
||||
"description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.",
|
||||
"type": "string"
|
||||
|
||||
@@ -10514,6 +10514,35 @@ experimental_realtime_start_instructions = "start instructions from config"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_realtime_end_instructions_load_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
experimental_realtime_end_instructions = "end instructions from config"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
|
||||
assert_eq!(
|
||||
cfg.experimental_realtime_end_instructions.as_deref(),
|
||||
Some("end instructions from config")
|
||||
);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.experimental_realtime_end_instructions.as_deref(),
|
||||
Some("end instructions from config")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_thread_config_endpoint_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
|
||||
@@ -959,6 +959,10 @@ pub struct Config {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
/// Experimental / do not use. Replaces the built-in realtime end
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// inactive.
|
||||
pub experimental_realtime_end_instructions: Option<String>,
|
||||
/// Experimental / do not use. When set, app-server fetches thread-scoped
|
||||
/// config from a remote service at this endpoint.
|
||||
pub experimental_thread_config_endpoint: Option<String>,
|
||||
@@ -3553,6 +3557,7 @@ impl Config {
|
||||
experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt,
|
||||
experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context,
|
||||
experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions,
|
||||
experimental_realtime_end_instructions: cfg.experimental_realtime_end_instructions,
|
||||
experimental_thread_config_endpoint: cfg.experimental_thread_config_endpoint,
|
||||
experimental_thread_store: thread_store_config(cfg.experimental_thread_store),
|
||||
forced_chatgpt_workspace_id,
|
||||
|
||||
@@ -5,12 +5,21 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct RealtimeEndInstructions {
|
||||
instructions: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
impl RealtimeEndInstructions {
|
||||
pub(crate) fn new(reason: impl Into<String>) -> Self {
|
||||
Self::with_instructions(reason, END_INSTRUCTIONS.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn with_instructions(
|
||||
reason: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instructions: instructions.into(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
@@ -33,6 +42,6 @@ impl ContextualUserFragment for RealtimeEndInstructions {
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n\nReason: {}\n", END_INSTRUCTIONS.trim(), self.reason)
|
||||
format!("\n{}\n\nReason: {}\n", self.instructions, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ pub(crate) fn build_realtime_update_item(
|
||||
previous.and_then(|item| item.realtime_active),
|
||||
next.realtime_active,
|
||||
) {
|
||||
(Some(true), false) => Some(RealtimeEndInstructions::new("inactive").render()),
|
||||
(Some(true), false) => Some(render_realtime_end(next)),
|
||||
(Some(false), true) | (None, true) => Some(
|
||||
if let Some(instructions) = next
|
||||
.config
|
||||
@@ -116,7 +116,20 @@ pub(crate) fn build_realtime_update_item(
|
||||
(None, false) => previous_turn_settings
|
||||
.and_then(|settings| settings.realtime_active)
|
||||
.filter(|realtime_active| *realtime_active)
|
||||
.map(|_| RealtimeEndInstructions::new("inactive").render()),
|
||||
.map(|_| render_realtime_end(next)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_realtime_end(next: &TurnContext) -> String {
|
||||
match next
|
||||
.config
|
||||
.experimental_realtime_end_instructions
|
||||
.as_deref()
|
||||
{
|
||||
Some(instructions) => {
|
||||
RealtimeEndInstructions::with_instructions("inactive", instructions).render()
|
||||
}
|
||||
None => RealtimeEndInstructions::new("inactive").render(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -614,6 +614,8 @@ pub(crate) async fn handle_start(
|
||||
struct PreparedRealtimeConversationStart {
|
||||
api_provider: ApiProvider,
|
||||
extra_headers: Option<HeaderMap>,
|
||||
realtime_end_instructions: Option<String>,
|
||||
realtime_start_instructions: Option<String>,
|
||||
requested_realtime_session_id: Option<String>,
|
||||
version: RealtimeWsVersion,
|
||||
session_config: RealtimeSessionConfig,
|
||||
@@ -671,6 +673,8 @@ async fn prepare_realtime_start(
|
||||
Ok(PreparedRealtimeConversationStart {
|
||||
api_provider,
|
||||
extra_headers,
|
||||
realtime_end_instructions: params.realtime_end_instructions,
|
||||
realtime_start_instructions: params.realtime_start_instructions,
|
||||
requested_realtime_session_id,
|
||||
version,
|
||||
session_config,
|
||||
@@ -787,6 +791,8 @@ async fn handle_start_inner(
|
||||
let PreparedRealtimeConversationStart {
|
||||
api_provider,
|
||||
extra_headers,
|
||||
realtime_end_instructions,
|
||||
realtime_start_instructions,
|
||||
requested_realtime_session_id,
|
||||
version,
|
||||
session_config,
|
||||
@@ -805,6 +811,8 @@ async fn handle_start_inner(
|
||||
sdp,
|
||||
};
|
||||
let start_output = sess.conversation.start(start).await?;
|
||||
sess.set_realtime_instruction_overrides(realtime_start_instructions, realtime_end_instructions)
|
||||
.await;
|
||||
|
||||
info!("realtime conversation started");
|
||||
|
||||
|
||||
@@ -601,6 +601,8 @@ impl Codex {
|
||||
personality: config.personality,
|
||||
base_instructions,
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: session_permission_profile_state_from_config(&config)?,
|
||||
@@ -1493,6 +1495,16 @@ impl Session {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn set_realtime_instruction_overrides(
|
||||
&self,
|
||||
realtime_start_instructions: Option<String>,
|
||||
realtime_end_instructions: Option<String>,
|
||||
) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.session_configuration.realtime_start_instructions = realtime_start_instructions;
|
||||
state.session_configuration.realtime_end_instructions = realtime_end_instructions;
|
||||
}
|
||||
|
||||
pub(crate) async fn provider(&self) -> ModelProviderInfo {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.provider.clone()
|
||||
|
||||
@@ -68,6 +68,9 @@ pub(crate) struct SessionConfiguration {
|
||||
/// Compact prompt override.
|
||||
pub(super) compact_prompt: Option<String>,
|
||||
|
||||
pub(super) realtime_start_instructions: Option<String>,
|
||||
pub(super) realtime_end_instructions: Option<String>,
|
||||
|
||||
/// When to escalate for approval for execution
|
||||
pub(super) approval_policy: Constrained<AskForApproval>,
|
||||
pub(super) approvals_reviewer: ApprovalsReviewer,
|
||||
|
||||
@@ -3178,6 +3178,8 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -3285,6 +3287,8 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -3817,6 +3821,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -4669,6 +4675,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -4777,6 +4785,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -5009,6 +5019,8 @@ async fn make_session_with_config_and_rx(
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -5111,6 +5123,8 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -6855,6 +6869,8 @@ where
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
permission_profile_state: config.permissions.permission_profile_state().clone(),
|
||||
@@ -7284,6 +7300,80 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_instruction_overrides_are_stored_for_future_turns() {
|
||||
let (session, _) = make_session_and_context().await;
|
||||
|
||||
session
|
||||
.set_realtime_instruction_overrides(
|
||||
Some("request realtime start".to_string()),
|
||||
Some("request realtime end".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(
|
||||
state.session_configuration.realtime_start_instructions,
|
||||
Some("request realtime start".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
state.session_configuration.realtime_end_instructions,
|
||||
Some("request realtime end".to_string())
|
||||
);
|
||||
|
||||
let per_turn_config = Session::build_per_turn_config(
|
||||
&state.session_configuration,
|
||||
state.session_configuration.cwd().clone(),
|
||||
);
|
||||
assert_eq!(
|
||||
per_turn_config
|
||||
.experimental_realtime_start_instructions
|
||||
.as_deref(),
|
||||
Some("request realtime start")
|
||||
);
|
||||
assert_eq!(
|
||||
per_turn_config
|
||||
.experimental_realtime_end_instructions
|
||||
.as_deref(),
|
||||
Some("request realtime end")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_realtime_instruction_config_takes_precedence_over_request_overrides() {
|
||||
let session = make_session_with_config(|config| {
|
||||
config.experimental_realtime_start_instructions = Some("local realtime start".to_string());
|
||||
config.experimental_realtime_end_instructions = Some("local realtime end".to_string());
|
||||
})
|
||||
.await
|
||||
.expect("create session");
|
||||
|
||||
session
|
||||
.set_realtime_instruction_overrides(
|
||||
Some("request realtime start".to_string()),
|
||||
Some("request realtime end".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let state = session.state.lock().await;
|
||||
let per_turn_config = Session::build_per_turn_config(
|
||||
&state.session_configuration,
|
||||
state.session_configuration.cwd().clone(),
|
||||
);
|
||||
assert_eq!(
|
||||
per_turn_config
|
||||
.experimental_realtime_start_instructions
|
||||
.as_deref(),
|
||||
Some("local realtime start")
|
||||
);
|
||||
assert_eq!(
|
||||
per_turn_config
|
||||
.experimental_realtime_end_instructions
|
||||
.as_deref(),
|
||||
Some("local realtime end")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
@@ -7294,6 +7384,8 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
Arc::make_mut(&mut current_context.config).experimental_realtime_start_instructions =
|
||||
Some("custom realtime start instructions".to_string());
|
||||
current_context.realtime_active = true;
|
||||
|
||||
let update_items = session
|
||||
@@ -7307,7 +7399,7 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l
|
||||
assert!(
|
||||
developer_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<realtime_conversation>")),
|
||||
.any(|text| text.contains("custom realtime start instructions")),
|
||||
"expected a realtime start update, got {developer_texts:?}"
|
||||
);
|
||||
}
|
||||
@@ -7322,6 +7414,8 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
Arc::make_mut(&mut current_context.config).experimental_realtime_end_instructions =
|
||||
Some("custom realtime end instructions".to_string());
|
||||
current_context.realtime_active = false;
|
||||
|
||||
let update_items = session
|
||||
@@ -7335,7 +7429,7 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being
|
||||
assert!(
|
||||
developer_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("Reason: inactive")),
|
||||
.any(|text| text.contains("custom realtime end instructions")),
|
||||
"expected a realtime end update, got {developer_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,6 +405,20 @@ impl Session {
|
||||
// todo(aibrahim): store this state somewhere else so we don't need to mut config
|
||||
let config = session_configuration.original_config_do_not_use.clone();
|
||||
let mut per_turn_config = (*config).clone();
|
||||
if per_turn_config
|
||||
.experimental_realtime_start_instructions
|
||||
.is_none()
|
||||
{
|
||||
per_turn_config.experimental_realtime_start_instructions =
|
||||
session_configuration.realtime_start_instructions.clone();
|
||||
}
|
||||
if per_turn_config
|
||||
.experimental_realtime_end_instructions
|
||||
.is_none()
|
||||
{
|
||||
per_turn_config.experimental_realtime_end_instructions =
|
||||
session_configuration.realtime_end_instructions.clone();
|
||||
}
|
||||
per_turn_config.cwd = cwd;
|
||||
per_turn_config.workspace_roots = session_configuration.workspace_roots.clone();
|
||||
per_turn_config
|
||||
|
||||
@@ -197,6 +197,8 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
|
||||
@@ -285,6 +285,8 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -425,6 +427,8 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -515,6 +519,8 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
|
||||
model: Some("session-override-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ConversationStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
@@ -687,6 +693,8 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join(
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ConversationStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
@@ -773,6 +781,8 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: Some(ConversationStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
@@ -860,6 +870,8 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -928,6 +940,8 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1020,6 +1034,8 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1066,6 +1082,8 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1159,6 +1177,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("old".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: Some("conv_old".to_string()),
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1184,6 +1204,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("new".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: Some("conv_new".to_string()),
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1280,6 +1302,8 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1344,6 +1368,8 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: None,
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1416,6 +1442,8 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt,
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1481,6 +1509,8 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1538,6 +1568,8 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1583,6 +1615,8 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1629,6 +1663,8 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("prompt from op".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1701,6 +1737,8 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("prompt from op".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1767,6 +1805,8 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("prompt from op".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1826,6 +1866,8 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -1937,6 +1979,8 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2044,6 +2088,8 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2103,6 +2149,8 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2182,6 +2230,8 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2278,6 +2328,8 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2376,6 +2428,8 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2512,6 +2566,8 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2663,6 +2719,8 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2764,6 +2822,8 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2866,6 +2926,8 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -2966,6 +3028,8 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3088,6 +3152,8 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3240,6 +3306,8 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3376,6 +3444,8 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
@@ -3528,6 +3598,8 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
|
||||
@@ -188,6 +188,8 @@ pub struct ConversationStartParams {
|
||||
/// Selects whether the realtime session should produce text or audio output.
|
||||
pub output_modality: RealtimeOutputModality,
|
||||
pub prompt: Option<Option<String>>,
|
||||
pub realtime_start_instructions: Option<String>,
|
||||
pub realtime_end_instructions: Option<String>,
|
||||
pub realtime_session_id: Option<String>,
|
||||
pub transport: Option<ConversationStartTransport>,
|
||||
/// Overrides the configured realtime protocol version for this session only.
|
||||
|
||||
Reference in New Issue
Block a user