Skip to content

Commit 807f8a4

Browse files
feat: expose outputSchema to user_turn/turn_start app_server API (#8377)
What changed - Added `outputSchema` support to the app-server APIs, mirroring `codex exec --output-schema` behavior. - V1 `sendUserTurn` now accepts `outputSchema` and constrains the final assistant message for that turn. - V2 `turn/start` now accepts `outputSchema` and constrains the final assistant message for that turn (explicitly per-turn only). Core behavior - `Op::UserTurn` already supported `final_output_json_schema`; now V1 `sendUserTurn` forwards `outputSchema` into that field. - `Op::UserInput` now carries `final_output_json_schema` for per-turn settings updates; core maps it into `SessionSettingsUpdate.final_output_json_schema` so it applies to the created turn context. - V2 `turn/start` does NOT persist the schema via `OverrideTurnContext` (it’s applied only for the current turn). Other overrides (cwd/model/etc) keep their existing persistent behavior. API / docs - `codex-rs/app-server-protocol/src/protocol/v1.rs`: add `output_schema: Option<serde_json::Value>` to `SendUserTurnParams` (serialized as `outputSchema`). - `codex-rs/app-server-protocol/src/protocol/v2.rs`: add `output_schema: Option<JsonValue>` to `TurnStartParams` (serialized as `outputSchema`). - `codex-rs/app-server/README.md`: document `outputSchema` for `turn/start` and clarify it applies only to the current turn. - `codex-rs/docs/codex_mcp_interface.md`: document `outputSchema` for v1 `sendUserTurn` and v2 `turn/start`. Tests added/updated - New app-server integration tests asserting `outputSchema` is forwarded into outbound `/responses` requests as `text.format`: - `codex-rs/app-server/tests/suite/output_schema.rs` - `codex-rs/app-server/tests/suite/v2/output_schema.rs` - Added per-turn semantics tests (schema does not leak to the next turn): - `send_user_turn_output_schema_is_per_turn_v1` - `turn_start_output_schema_is_per_turn_v2` - Added protocol wire-compat tests for the merged op: - serialize omits `final_output_json_schema` when `None` - deserialize works when field is missing - serialize includes `final_output_json_schema` when `Some(schema)` Call site updates (high level) - Updated all `Op::UserInput { .. }` constructions to include `final_output_json_schema`: - `codex-rs/app-server/src/codex_message_processor.rs` - `codex-rs/core/src/codex_delegate.rs` - `codex-rs/mcp-server/src/codex_tool_runner.rs` - `codex-rs/tui/src/chatwidget.rs` - `codex-rs/tui2/src/chatwidget.rs` - plus impacted core tests. Validation - `just fmt` - `cargo test -p codex-core` - `cargo test -p codex-app-server` - `cargo test -p codex-mcp-server` - `cargo test -p codex-tui` - `cargo test -p codex-tui2` - `cargo test -p codex-protocol` - `cargo clippy --all-features --tests --profile dev --fix -- -D warnings`
1 parent 1d8e2b4 commit 807f8a4

32 files changed

+722
-8
lines changed

codex-rs/app-server-protocol/src/protocol/v1.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ pub struct SendUserTurnParams {
384384
pub model: String,
385385
pub effort: Option<ReasoningEffort>,
386386
pub summary: ReasoningSummary,
387+
/// Optional JSON Schema used to constrain the final assistant message for this turn.
388+
pub output_schema: Option<serde_json::Value>,
387389
}
388390

389391
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,8 @@ pub struct TurnStartParams {
13191319
pub effort: Option<ReasoningEffort>,
13201320
/// Override the reasoning summary for this turn and subsequent turns.
13211321
pub summary: Option<ReasoningSummary>,
1322+
/// Optional JSON Schema used to constrain the final assistant message for this turn.
1323+
pub output_schema: Option<JsonValue>,
13221324
}
13231325

13241326
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

codex-rs/app-server/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
162162
- `{"type":"image","url":"https://…png"}`
163163
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
164164

165-
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
165+
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn.
166166

167167
```json
168168
{ "method": "turn/start", "id": 30, "params": {
@@ -178,7 +178,14 @@ You can optionally specify config overrides on the new turn. If specified, these
178178
},
179179
"model": "gpt-5.1-codex",
180180
"effort": "medium",
181-
"summary": "concise"
181+
"summary": "concise",
182+
// Optional JSON Schema to constrain the final assistant message for this turn.
183+
"outputSchema": {
184+
"type": "object",
185+
"properties": { "answer": { "type": "string" } },
186+
"required": ["answer"],
187+
"additionalProperties": false
188+
}
182189
} }
183190
{ "id": 30, "result": { "turn": {
184191
"id": "turn_456",

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2579,6 +2579,7 @@ impl CodexMessageProcessor {
25792579
let _ = conversation
25802580
.submit(Op::UserInput {
25812581
items: mapped_items,
2582+
final_output_json_schema: None,
25822583
})
25832584
.await;
25842585

@@ -2598,6 +2599,7 @@ impl CodexMessageProcessor {
25982599
model,
25992600
effort,
26002601
summary,
2602+
output_schema,
26012603
} = params;
26022604

26032605
let Ok(conversation) = self
@@ -2632,7 +2634,7 @@ impl CodexMessageProcessor {
26322634
model,
26332635
effort,
26342636
summary,
2635-
final_output_json_schema: None,
2637+
final_output_json_schema: output_schema,
26362638
})
26372639
.await;
26382640

@@ -2741,6 +2743,7 @@ impl CodexMessageProcessor {
27412743
let turn_id = conversation
27422744
.submit(Op::UserInput {
27432745
items: mapped_items,
2746+
final_output_json_schema: params.output_schema,
27442747
})
27452748
.await;
27462749

codex-rs/app-server/tests/suite/codex_message_processor_flow.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
305305
model: "mock-model".to_string(),
306306
effort: Some(ReasoningEffort::Medium),
307307
summary: ReasoningSummary::Auto,
308+
output_schema: None,
308309
})
309310
.await?;
310311
// Acknowledge sendUserTurn
@@ -418,6 +419,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
418419
model: model.clone(),
419420
effort: Some(ReasoningEffort::Medium),
420421
summary: ReasoningSummary::Auto,
422+
output_schema: None,
421423
})
422424
.await?;
423425
timeout(
@@ -443,6 +445,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
443445
model: model.clone(),
444446
effort: Some(ReasoningEffort::Medium),
445447
summary: ReasoningSummary::Auto,
448+
output_schema: None,
446449
})
447450
.await?;
448451
timeout(

codex-rs/app-server/tests/suite/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod fuzzy_file_search;
77
mod interrupt;
88
mod list_resume;
99
mod login;
10+
mod output_schema;
1011
mod send_message;
1112
mod set_default_model;
1213
mod user_agent;
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
use anyhow::Result;
2+
use app_test_support::McpProcess;
3+
use app_test_support::to_response;
4+
use codex_app_server_protocol::AddConversationListenerParams;
5+
use codex_app_server_protocol::InputItem;
6+
use codex_app_server_protocol::JSONRPCResponse;
7+
use codex_app_server_protocol::NewConversationParams;
8+
use codex_app_server_protocol::NewConversationResponse;
9+
use codex_app_server_protocol::RequestId;
10+
use codex_app_server_protocol::SendUserTurnParams;
11+
use codex_app_server_protocol::SendUserTurnResponse;
12+
use codex_core::protocol::AskForApproval;
13+
use codex_core::protocol::SandboxPolicy;
14+
use codex_protocol::config_types::ReasoningSummary;
15+
use codex_protocol::openai_models::ReasoningEffort;
16+
use core_test_support::responses;
17+
use core_test_support::skip_if_no_network;
18+
use pretty_assertions::assert_eq;
19+
use std::path::Path;
20+
use tempfile::TempDir;
21+
use tokio::time::timeout;
22+
23+
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
24+
25+
#[tokio::test]
26+
async fn send_user_turn_accepts_output_schema_v1() -> Result<()> {
27+
skip_if_no_network!(Ok(()));
28+
29+
let server = responses::start_mock_server().await;
30+
let body = responses::sse(vec![
31+
responses::ev_response_created("resp-1"),
32+
responses::ev_assistant_message("msg-1", "Done"),
33+
responses::ev_completed("resp-1"),
34+
]);
35+
let response_mock = responses::mount_sse_once(&server, body).await;
36+
37+
let codex_home = TempDir::new()?;
38+
create_config_toml(codex_home.path(), &server.uri())?;
39+
40+
let mut mcp = McpProcess::new(codex_home.path()).await?;
41+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
42+
43+
let new_conv_id = mcp
44+
.send_new_conversation_request(NewConversationParams {
45+
..Default::default()
46+
})
47+
.await?;
48+
let new_conv_resp: JSONRPCResponse = timeout(
49+
DEFAULT_READ_TIMEOUT,
50+
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
51+
)
52+
.await??;
53+
let NewConversationResponse {
54+
conversation_id, ..
55+
} = to_response::<NewConversationResponse>(new_conv_resp)?;
56+
57+
let listener_id = mcp
58+
.send_add_conversation_listener_request(AddConversationListenerParams {
59+
conversation_id,
60+
experimental_raw_events: false,
61+
})
62+
.await?;
63+
timeout(
64+
DEFAULT_READ_TIMEOUT,
65+
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
66+
)
67+
.await??;
68+
69+
let output_schema = serde_json::json!({
70+
"type": "object",
71+
"properties": {
72+
"answer": { "type": "string" }
73+
},
74+
"required": ["answer"],
75+
"additionalProperties": false
76+
});
77+
78+
let send_turn_id = mcp
79+
.send_send_user_turn_request(SendUserTurnParams {
80+
conversation_id,
81+
items: vec![InputItem::Text {
82+
text: "Hello".to_string(),
83+
}],
84+
cwd: codex_home.path().to_path_buf(),
85+
approval_policy: AskForApproval::Never,
86+
sandbox_policy: SandboxPolicy::new_read_only_policy(),
87+
model: "mock-model".to_string(),
88+
effort: Some(ReasoningEffort::Medium),
89+
summary: ReasoningSummary::Auto,
90+
output_schema: Some(output_schema.clone()),
91+
})
92+
.await?;
93+
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
94+
timeout(
95+
DEFAULT_READ_TIMEOUT,
96+
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
97+
)
98+
.await??,
99+
)?;
100+
101+
timeout(
102+
DEFAULT_READ_TIMEOUT,
103+
mcp.read_stream_until_notification_message("codex/event/task_complete"),
104+
)
105+
.await??;
106+
107+
let request = response_mock.single_request();
108+
let payload = request.body_json();
109+
let text = payload.get("text").expect("request missing text field");
110+
let format = text
111+
.get("format")
112+
.expect("request missing text.format field");
113+
assert_eq!(
114+
format,
115+
&serde_json::json!({
116+
"name": "codex_output_schema",
117+
"type": "json_schema",
118+
"strict": true,
119+
"schema": output_schema,
120+
})
121+
);
122+
123+
Ok(())
124+
}
125+
126+
#[tokio::test]
127+
async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
128+
skip_if_no_network!(Ok(()));
129+
130+
let server = responses::start_mock_server().await;
131+
let body1 = responses::sse(vec![
132+
responses::ev_response_created("resp-1"),
133+
responses::ev_assistant_message("msg-1", "Done"),
134+
responses::ev_completed("resp-1"),
135+
]);
136+
let response_mock1 = responses::mount_sse_once(&server, body1).await;
137+
138+
let codex_home = TempDir::new()?;
139+
create_config_toml(codex_home.path(), &server.uri())?;
140+
141+
let mut mcp = McpProcess::new(codex_home.path()).await?;
142+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
143+
144+
let new_conv_id = mcp
145+
.send_new_conversation_request(NewConversationParams {
146+
..Default::default()
147+
})
148+
.await?;
149+
let new_conv_resp: JSONRPCResponse = timeout(
150+
DEFAULT_READ_TIMEOUT,
151+
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
152+
)
153+
.await??;
154+
let NewConversationResponse {
155+
conversation_id, ..
156+
} = to_response::<NewConversationResponse>(new_conv_resp)?;
157+
158+
let listener_id = mcp
159+
.send_add_conversation_listener_request(AddConversationListenerParams {
160+
conversation_id,
161+
experimental_raw_events: false,
162+
})
163+
.await?;
164+
timeout(
165+
DEFAULT_READ_TIMEOUT,
166+
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
167+
)
168+
.await??;
169+
170+
let output_schema = serde_json::json!({
171+
"type": "object",
172+
"properties": {
173+
"answer": { "type": "string" }
174+
},
175+
"required": ["answer"],
176+
"additionalProperties": false
177+
});
178+
179+
let send_turn_id = mcp
180+
.send_send_user_turn_request(SendUserTurnParams {
181+
conversation_id,
182+
items: vec![InputItem::Text {
183+
text: "Hello".to_string(),
184+
}],
185+
cwd: codex_home.path().to_path_buf(),
186+
approval_policy: AskForApproval::Never,
187+
sandbox_policy: SandboxPolicy::new_read_only_policy(),
188+
model: "mock-model".to_string(),
189+
effort: Some(ReasoningEffort::Medium),
190+
summary: ReasoningSummary::Auto,
191+
output_schema: Some(output_schema.clone()),
192+
})
193+
.await?;
194+
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
195+
timeout(
196+
DEFAULT_READ_TIMEOUT,
197+
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
198+
)
199+
.await??,
200+
)?;
201+
202+
timeout(
203+
DEFAULT_READ_TIMEOUT,
204+
mcp.read_stream_until_notification_message("codex/event/task_complete"),
205+
)
206+
.await??;
207+
208+
let payload1 = response_mock1.single_request().body_json();
209+
assert_eq!(
210+
payload1.pointer("/text/format"),
211+
Some(&serde_json::json!({
212+
"name": "codex_output_schema",
213+
"type": "json_schema",
214+
"strict": true,
215+
"schema": output_schema,
216+
}))
217+
);
218+
219+
let body2 = responses::sse(vec![
220+
responses::ev_response_created("resp-2"),
221+
responses::ev_assistant_message("msg-2", "Done"),
222+
responses::ev_completed("resp-2"),
223+
]);
224+
let response_mock2 = responses::mount_sse_once(&server, body2).await;
225+
226+
let send_turn_id_2 = mcp
227+
.send_send_user_turn_request(SendUserTurnParams {
228+
conversation_id,
229+
items: vec![InputItem::Text {
230+
text: "Hello again".to_string(),
231+
}],
232+
cwd: codex_home.path().to_path_buf(),
233+
approval_policy: AskForApproval::Never,
234+
sandbox_policy: SandboxPolicy::new_read_only_policy(),
235+
model: "mock-model".to_string(),
236+
effort: Some(ReasoningEffort::Medium),
237+
summary: ReasoningSummary::Auto,
238+
output_schema: None,
239+
})
240+
.await?;
241+
let _send_turn_resp_2: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
242+
timeout(
243+
DEFAULT_READ_TIMEOUT,
244+
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id_2)),
245+
)
246+
.await??,
247+
)?;
248+
249+
timeout(
250+
DEFAULT_READ_TIMEOUT,
251+
mcp.read_stream_until_notification_message("codex/event/task_complete"),
252+
)
253+
.await??;
254+
255+
let payload2 = response_mock2.single_request().body_json();
256+
assert_eq!(payload2.pointer("/text/format"), None);
257+
258+
Ok(())
259+
}
260+
261+
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
262+
let config_toml = codex_home.join("config.toml");
263+
std::fs::write(
264+
config_toml,
265+
format!(
266+
r#"
267+
model = "mock-model"
268+
approval_policy = "never"
269+
sandbox_mode = "read-only"
270+
271+
model_provider = "mock_provider"
272+
273+
[model_providers.mock_provider]
274+
name = "Mock provider for test"
275+
base_url = "{server_uri}/v1"
276+
wire_api = "responses"
277+
request_max_retries = 0
278+
stream_max_retries = 0
279+
"#
280+
),
281+
)
282+
}

codex-rs/app-server/tests/suite/v2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod account;
22
mod config_rpc;
33
mod model_list;
4+
mod output_schema;
45
mod rate_limits;
56
mod review;
67
mod thread_archive;

0 commit comments

Comments
 (0)