protocol: remove submission-side serde from Op (#26674)

## Why

Submission-side `Op` payloads are now an internal handoff inside the
Rust codebase, so keeping a stable serde contract there adds complexity
without a real wire consumer.

## What changed

- remove serde/schema annotations from `Submission`, `Op`, and
submission-only payload types like thread settings overrides, additional
context, realtime conversation params, `TurnEnvironmentSelection`, and
`RequestUserInputResponse`
- delete the `Op` serialization tests and the now-unused double-option
prompt serde helper
- keep event/API-facing serialization where it is still required, and
serialize the `request_user_input` tool output from its wire payload
instead of the core response struct
- update `protocol_v1.md` to call out that events remain the serialized
transport surface while submission payloads are implementation details

## Testing

- `just test -p codex-protocol`
- `cargo check -p codex-core -p codex-app-server -p codex-thread-store`
- `just test -p codex-core request_user_input`
This commit is contained in:
pakrym-oai
2026-06-05 15:41:13 -07:00
committed by GitHub
Unverified
parent 479a14cf59
commit 470c20bf98
2 changed files with 13 additions and 312 deletions
+2 -1
View File
@@ -54,6 +54,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t
- These are messages sent on the `SQ` (UI -> `Codex`)
- Has an string ID provided by the UI, referred to as `sub_id`
- `Op` refers to the enum of all possible `Submission` payloads
- In the current codebase these are primarily in-process Rust types rather than a stable serde wire contract
- This enum is `non_exhaustive`; variants can be added at future dates
- `Event`
- These are messages sent on the `EQ` (`Codex` -> UI)
@@ -103,7 +104,7 @@ The `response_id` returned from each turn matches the OpenAI `response_id` store
Can operate over any transport that supports bi-directional streaming. - cross-thread channels - IPC channels - stdin/stdout - TCP - HTTP2 - gRPC
Non-framed transports, such as stdin/stdout and TCP, should use newline-delimited JSON in sending messages.
Events still serialize cleanly to newline-delimited JSON for non-framed transports, such as stdin/stdout and TCP. Submission payloads should be treated as implementation details unless a specific transport owns an explicit adapter.
## Example Flows
+11 -311
View File
@@ -106,7 +106,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[derive(Debug, Clone, PartialEq)]
pub struct TurnEnvironmentSelection {
pub environment_id: String,
pub cwd: AbsolutePathBuf,
@@ -124,17 +124,15 @@ impl GitSha {
}
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[derive(Debug, Clone)]
pub struct Submission {
/// Unique id for this Submission to correlate with Events
pub id: String,
/// Payload
pub op: Op,
/// Client-provided id for the user message represented by `Op::UserInput`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_user_message_id: Option<String>,
/// Optional W3C trace carrier propagated across async submission handoffs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<W3cTraceContext>,
}
@@ -149,34 +147,23 @@ pub struct W3cTraceContext {
}
/// Config payload for refreshing MCP servers.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[derive(Debug, Clone, PartialEq)]
pub struct McpServerRefreshConfig {
pub mcp_servers: Value,
pub mcp_oauth_credentials_store_mode: Value,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq)]
pub struct ConversationStartParams {
/// Selects whether the realtime session should produce text or audio output.
pub output_modality: RealtimeOutputModality,
#[serde(
default,
deserialize_with = "conversation_start_prompt_serde::deserialize",
serialize_with = "conversation_start_prompt_serde::serialize",
skip_serializing_if = "Option::is_none"
)]
pub prompt: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub realtime_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<ConversationStartTransport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<RealtimeVoice>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
#[derive(Debug, Clone, PartialEq)]
pub enum ConversationStartTransport {
Websocket,
Webrtc { sdp: String },
@@ -189,28 +176,6 @@ pub enum RealtimeOutputModality {
Audio,
}
mod conversation_start_prompt_serde {
use serde::Deserializer;
use serde::Serializer;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
where
D: Deserializer<'de>,
{
serde_with::rust::double_option::deserialize(deserializer)
}
pub(crate) fn serialize<S>(
value: &Option<Option<String>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serde_with::rust::double_option::serialize(value, serializer)
}
}
#[derive(
Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS, Ord, PartialOrd,
)]
@@ -391,109 +356,92 @@ pub enum RealtimeEvent {
Error(String),
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq)]
pub struct ConversationAudioParams {
pub frame: RealtimeAudioFrame,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq)]
pub struct ConversationTextParams {
pub text: String,
}
/// Persistent thread-settings overrides that can be applied before user input or
/// on their own.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ThreadSettingsOverrides {
/// Updated `cwd` for sandbox/tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<AbsolutePathBuf>,
/// Updated runtime workspace roots used to materialize symbolic
/// `:workspace_roots` filesystem permissions.
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated profile-defined workspace roots for status summaries and
/// per-turn config reconstruction.
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
/// Updated approval reviewer for future approval prompts.
#[serde(skip_serializing_if = "Option::is_none")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Updated sandbox policy for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<SandboxPolicy>,
/// Updated permissions profile for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_profile: Option<PermissionProfile>,
/// Named or built-in profile that produced `permission_profile`, if the
/// update selected a profile rather than supplying raw permissions.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_permission_profile: Option<ActivePermissionProfile>,
/// Updated Windows sandbox mode for tool execution.
#[serde(skip_serializing_if = "Option::is_none")]
pub windows_sandbox_level: Option<WindowsSandboxLevel>,
/// Updated model slug. When set, the model info is derived automatically.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Updated reasoning effort (honored only for reasoning-capable models).
///
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the
/// effort, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<Option<ReasoningEffortConfig>>,
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<ReasoningSummaryConfig>,
/// Updated service tier preference for future turns.
///
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
/// preference, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<Option<String>>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, effort, and developer instructions if set.
#[serde(skip_serializing_if = "Option::is_none")]
pub collaboration_mode: Option<CollaborationMode>,
/// Updated personality preference.
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
}
/// Source classification for client-supplied context.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdditionalContextKind {
Untrusted,
Application,
}
/// Client-supplied context keyed by an opaque source identifier.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdditionalContextEntry {
pub value: String,
pub kind: AdditionalContextKind,
}
/// Submission operation
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
#[non_exhaustive]
pub enum Op {
@@ -525,20 +473,15 @@ pub enum Op {
/// User input items, see `InputItem`
items: Vec<UserInput>,
/// Optional turn-scoped environments.
#[serde(default, skip_serializing_if = "Option::is_none")]
environments: Option<Vec<TurnEnvironmentSelection>>,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
final_output_json_schema: Option<Value>,
/// Optional turn-scoped Responses API `client_metadata`.
#[serde(default, skip_serializing_if = "Option::is_none")]
responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Client-supplied context fragments keyed by an opaque source identifier.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
additional_context: BTreeMap<String, AdditionalContextEntry>,
/// Persistent thread-settings overrides to apply before the input.
#[serde(default, flatten)]
thread_settings: ThreadSettingsOverrides,
},
@@ -548,7 +491,6 @@ pub enum Op {
/// preserve caller order between both kinds of mutation.
ThreadSettings {
/// Persistent thread-settings overrides to apply.
#[serde(flatten)]
thread_settings: ThreadSettingsOverrides,
},
@@ -563,7 +505,6 @@ pub enum Op {
/// The id of the submission we are approving
id: String,
/// Turn id associated with the approval event, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_id: Option<String>,
/// The user's decision in response to the request.
decision: ReviewDecision,
@@ -586,15 +527,12 @@ pub enum Op {
/// User's decision for the request.
decision: ElicitationAction,
/// Structured user input supplied for accepted elicitations.
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<Value>,
/// Optional client metadata associated with the elicitation response.
#[serde(default, skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
},
/// Resolve a request_user_input tool call.
#[serde(rename = "user_input_answer", alias = "request_user_input_response")]
UserInputAnswer {
/// Turn id for the in-flight request.
id: String,
@@ -4950,146 +4888,6 @@ mod tests {
assert!(event.affects_turn_status());
}
#[test]
fn conversation_op_serializes_as_unnested_variants() {
let audio = Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24_000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
});
let start = Op::RealtimeConversationStart(ConversationStartParams {
output_modality: RealtimeOutputModality::Audio,
prompt: Some(Some("be helpful".to_string())),
realtime_session_id: Some("conv_1".to_string()),
transport: None,
voice: None,
});
let webrtc_start = Op::RealtimeConversationStart(ConversationStartParams {
output_modality: RealtimeOutputModality::Audio,
prompt: Some(Some("be helpful".to_string())),
realtime_session_id: Some("conv_1".to_string()),
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
voice: Some(RealtimeVoice::Cove),
});
let text = Op::RealtimeConversationText(ConversationTextParams {
text: "hello".to_string(),
});
let close = Op::RealtimeConversationClose;
let default_prompt_start = Op::RealtimeConversationStart(ConversationStartParams {
output_modality: RealtimeOutputModality::Audio,
prompt: None,
realtime_session_id: None,
transport: None,
voice: None,
});
let null_prompt_start = Op::RealtimeConversationStart(ConversationStartParams {
output_modality: RealtimeOutputModality::Audio,
prompt: Some(None),
realtime_session_id: None,
transport: None,
voice: None,
});
let list_voices = Op::RealtimeConversationListVoices;
assert_eq!(
serde_json::to_value(&start).unwrap(),
json!({
"type": "realtime_conversation_start",
"output_modality": "audio",
"prompt": "be helpful",
"realtime_session_id": "conv_1"
})
);
assert_eq!(
serde_json::to_value(&default_prompt_start).unwrap(),
json!({
"type": "realtime_conversation_start",
"output_modality": "audio"
})
);
assert_eq!(
serde_json::to_value(&null_prompt_start).unwrap(),
json!({
"type": "realtime_conversation_start",
"output_modality": "audio",
"prompt": null
})
);
assert_eq!(
serde_json::from_value::<Op>(json!({
"type": "realtime_conversation_start",
"output_modality": "audio"
}))
.unwrap(),
default_prompt_start
);
assert_eq!(
serde_json::from_value::<Op>(json!({
"type": "realtime_conversation_start",
"output_modality": "audio",
"prompt": null
}))
.unwrap(),
null_prompt_start
);
assert_eq!(
serde_json::to_value(&audio).unwrap(),
json!({
"type": "realtime_conversation_audio",
"frame": {
"data": "AQID",
"sample_rate": 24000,
"num_channels": 1,
"samples_per_channel": 480
}
})
);
assert_eq!(
serde_json::from_value::<Op>(serde_json::to_value(&text).unwrap()).unwrap(),
text
);
assert_eq!(
serde_json::to_value(&close).unwrap(),
json!({
"type": "realtime_conversation_close"
})
);
assert_eq!(
serde_json::from_value::<Op>(serde_json::to_value(&close).unwrap()).unwrap(),
close
);
assert_eq!(
serde_json::to_value(&list_voices).unwrap(),
json!({
"type": "realtime_conversation_list_voices"
})
);
assert_eq!(
serde_json::from_value::<Op>(serde_json::to_value(&list_voices).unwrap()).unwrap(),
list_voices
);
assert_eq!(
serde_json::to_value(&webrtc_start).unwrap(),
json!({
"type": "realtime_conversation_start",
"output_modality": "audio",
"prompt": "be helpful",
"realtime_session_id": "conv_1",
"transport": {
"type": "webrtc",
"sdp": "v=offer\r\n"
},
"voice": "cove"
})
);
}
#[test]
fn realtime_conversation_started_event_uses_realtime_session_id() {
let event = RealtimeConversationStartedEvent {
@@ -5140,104 +4938,6 @@ mod tests {
);
}
#[test]
fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> {
let op = Op::UserInput {
environments: None,
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(op)?;
assert_eq!(json_op, json!({ "type": "user_input", "items": [] }));
Ok(())
}
#[test]
fn user_input_deserializes_without_final_output_json_schema_field() -> Result<()> {
let op: Op = serde_json::from_value(json!({ "type": "user_input", "items": [] }))?;
assert_eq!(
op,
Op::UserInput {
environments: None,
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
}
);
Ok(())
}
#[test]
fn user_input_serialization_includes_final_output_json_schema_when_some() -> Result<()> {
let schema = json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let op = Op::UserInput {
environments: None,
items: Vec::new(),
final_output_json_schema: Some(schema.clone()),
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(op)?;
assert_eq!(
json_op,
json!({
"type": "user_input",
"items": [],
"final_output_json_schema": schema,
})
);
Ok(())
}
#[test]
fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> {
let op = Op::UserInput {
environments: None,
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: Some(HashMap::from([(
"fiber_run_id".to_string(),
"fiber-123".to_string(),
)])),
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(&op)?;
assert_eq!(
json_op,
json!({
"type": "user_input",
"items": [],
"responsesapi_client_metadata": {
"fiber_run_id": "fiber-123",
}
})
);
assert_eq!(serde_json::from_value::<Op>(json_op)?, op);
Ok(())
}
#[test]
fn user_input_text_serializes_empty_text_elements() -> Result<()> {
let input = UserInput::Text {