mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
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`
426 lines
13 KiB
Rust
426 lines
13 KiB
Rust
#![cfg(not(target_os = "windows"))]
|
|
|
|
use anyhow::Ok;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::ItemCompletedEvent;
|
|
use codex_core::protocol::ItemStartedEvent;
|
|
use codex_core::protocol::Op;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_message_item_added;
|
|
use core_test_support::responses::ev_output_text_delta;
|
|
use core_test_support::responses::ev_reasoning_item;
|
|
use core_test_support::responses::ev_reasoning_item_added;
|
|
use core_test_support::responses::ev_reasoning_summary_text_delta;
|
|
use core_test_support::responses::ev_reasoning_text_delta;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::ev_web_search_call_added;
|
|
use core_test_support::responses::ev_web_search_call_done;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::test_codex::TestCodex;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event_match;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn user_message_item_is_emitted() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
|
|
|
let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]);
|
|
mount_sse_once(&server, first_response).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: (vec![UserInput::Text {
|
|
text: "please inspect sample.txt".into(),
|
|
}]),
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let started_item = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::UserMessage(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let completed_item = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::UserMessage(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(started_item.id, completed_item.id);
|
|
assert_eq!(
|
|
started_item.content,
|
|
vec![UserInput::Text {
|
|
text: "please inspect sample.txt".into(),
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
completed_item.content,
|
|
vec![UserInput::Text {
|
|
text: "please inspect sample.txt".into(),
|
|
}]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn assistant_message_item_is_emitted() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
|
|
|
let first_response = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "all done"),
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, first_response).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "please summarize results".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let started = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::AgentMessage(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let completed = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::AgentMessage(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(started.id, completed.id);
|
|
let Some(codex_protocol::items::AgentMessageContent::Text { text }) = completed.content.first()
|
|
else {
|
|
panic!("expected agent message text content");
|
|
};
|
|
assert_eq!(text, "all done");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reasoning_item_is_emitted() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
|
|
|
let reasoning_item = ev_reasoning_item(
|
|
"reasoning-1",
|
|
&["Consider inputs", "Compute output"],
|
|
&["Detailed reasoning trace"],
|
|
);
|
|
|
|
let first_response = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
reasoning_item,
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, first_response).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "explain your reasoning".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let started = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::Reasoning(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let completed = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::Reasoning(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(started.id, completed.id);
|
|
assert_eq!(
|
|
completed.summary_text,
|
|
vec!["Consider inputs".to_string(), "Compute output".to_string()]
|
|
);
|
|
assert_eq!(
|
|
completed.raw_content,
|
|
vec!["Detailed reasoning trace".to_string()]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
|
|
|
let web_search_added =
|
|
ev_web_search_call_added("web-search-1", "in_progress", "weather seattle");
|
|
let web_search_done = ev_web_search_call_done("web-search-1", "completed", "weather seattle");
|
|
|
|
let first_response = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
web_search_added,
|
|
web_search_done,
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, first_response).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "find the weather".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let started = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::WebSearch(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let completed = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::WebSearch(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(started.id, completed.id);
|
|
assert_eq!(completed.query, "weather seattle");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex {
|
|
codex,
|
|
session_configured,
|
|
..
|
|
} = test_codex().build(&server).await?;
|
|
|
|
let stream = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_message_item_added("msg-1", ""),
|
|
ev_output_text_delta("streamed response"),
|
|
ev_assistant_message("msg-1", "streamed response"),
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, stream).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "please stream text".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let (started_turn_id, started_item) = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
turn_id,
|
|
item: TurnItem::AgentMessage(item),
|
|
..
|
|
}) => Some((turn_id.clone(), item.clone())),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::AgentMessageContentDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::AgentMessageDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let completed_item = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::AgentMessage(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let session_id = session_configured.session_id.to_string();
|
|
assert_eq!(delta_event.thread_id, session_id);
|
|
assert_eq!(delta_event.turn_id, started_turn_id);
|
|
assert_eq!(delta_event.item_id, started_item.id);
|
|
assert_eq!(delta_event.delta, "streamed response");
|
|
assert_eq!(legacy_delta.delta, "streamed response");
|
|
assert_eq!(completed_item.id, started_item.id);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
|
|
|
let stream = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_reasoning_item_added("reasoning-1", &[""]),
|
|
ev_reasoning_summary_text_delta("step one"),
|
|
ev_reasoning_item("reasoning-1", &["step one"], &[]),
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, stream).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "reason through it".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let reasoning_item = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::Reasoning(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ReasoningContentDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::AgentReasoningDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(delta_event.item_id, reasoning_item.id);
|
|
assert_eq!(delta_event.delta, "step one");
|
|
assert_eq!(legacy_delta.delta, "step one");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
let TestCodex { codex, .. } = test_codex()
|
|
.with_config(|config| {
|
|
config.show_raw_agent_reasoning = true;
|
|
})
|
|
.build(&server)
|
|
.await?;
|
|
|
|
let stream = sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_reasoning_item_added("reasoning-raw", &[""]),
|
|
ev_reasoning_text_delta("raw detail"),
|
|
ev_reasoning_item("reasoning-raw", &["complete"], &["raw detail"]),
|
|
ev_completed("resp-1"),
|
|
]);
|
|
mount_sse_once(&server, stream).await;
|
|
|
|
codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "show raw reasoning".into(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
})
|
|
.await?;
|
|
|
|
let reasoning_item = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::Reasoning(item),
|
|
..
|
|
}) => Some(item.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::ReasoningRawContentDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
|
EventMsg::AgentReasoningRawContentDelta(event) => Some(event.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(delta_event.item_id, reasoning_item.id);
|
|
assert_eq!(delta_event.delta, "raw detail");
|
|
assert_eq!(legacy_delta.delta, "raw detail");
|
|
|
|
Ok(())
|
|
}
|