Move item event mapping into app-server-protocol (#20299)

## Why

Follow-up to #20291.

The v2 item-event-to-notification translation had been embedded in
`app-server/src/bespoke_event_handling.rs`, which made it hard to reuse
anywhere else. This PR moves that stateless mapping into shared protocol
code so other entry points can produce the same `ServerNotification`
payloads without copying app-server logic.

That also lets `thread-manager-sample` demonstrate the same notification
surface that the app server exposes, instead of only printing the final
assistant message.

## What changed

- move `item_event_to_server_notification` into
`codex-app-server-protocol::protocol::event_mapping`
- keep the mapper tests next to the shared implementation in
`codex-app-server-protocol`
- re-export the mapper from `codex-core-api` so lightweight consumers
can use it without reaching into `app-server-protocol` directly
- simplify `app-server/src/bespoke_event_handling.rs` so it delegates
the stateless event-to-notification projection to the shared helper
- update `thread-manager-sample` to:
  - print mapped notifications as newline-delimited JSON
  - use the shared mapper through `codex-core-api`
- enable the default feature set so the sample exposes the normal tool
surface
- use a `read_only` permission profile so shell commands can run in the
sample without widening permissions

## Testing

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-core-api`
- `cargo test -p codex-app-server bespoke_event_handling::tests`
- `cargo test -p codex-thread-manager-sample`
- `cargo run -p codex-thread-manager-sample -- "briefly explore the repo
with pwd and ls, then summarize it"`
This commit is contained in:
pakrym-oai
2026-04-30 11:02:13 -07:00
committed by GitHub
Unverified
parent c70cdc108f
commit 5cc5f12efc
9 changed files with 955 additions and 899 deletions
+68 -19
View File
@@ -23,6 +23,7 @@ use codex_core_api::EnvironmentManager;
use codex_core_api::EnvironmentManagerArgs;
use codex_core_api::EventMsg;
use codex_core_api::ExecServerRuntimePaths;
use codex_core_api::Features;
use codex_core_api::GhostSnapshotConfig;
use codex_core_api::History;
use codex_core_api::MemoriesConfig;
@@ -53,13 +54,14 @@ use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::built_in_model_providers;
use codex_core_api::find_codex_home;
use codex_core_api::item_event_to_server_notification;
use codex_core_api::set_default_originator;
use codex_core_api::thread_store_from_config;
#[derive(Debug, Parser)]
#[command(
name = "codex-thread-manager-sample",
about = "Run one Codex turn through ThreadManager and print the final assistant output."
about = "Run one Codex turn through ThreadManager and print mapped notifications as newline-delimited JSON."
)]
struct Args {
/// Override the model for this run.
@@ -125,19 +127,14 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await
.context("start Codex thread")?;
let turn_output = run_turn(&thread, prompt).await;
let thread_id_string = thread_id.to_string();
let turn_output = run_turn(&thread, &thread_id_string, prompt).await;
let shutdown_result = thread.shutdown_and_wait().await;
let _ = thread_manager.remove_thread(&thread_id).await;
let output = turn_output?;
turn_output?;
shutdown_result.context("shut down Codex thread")?;
let mut stdout = std::io::stdout().lock();
stdout.write_all(output.as_bytes())?;
if !output.ends_with('\n') {
stdout.write_all(b"\n")?;
}
Ok(())
}
@@ -151,7 +148,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
.context("OpenAI model provider should be available")?
.clone();
Ok(Config {
let mut config = Config {
config_layer_stack: ConfigLayerStack::default(),
startup_warnings: Vec::new(),
model,
@@ -164,7 +161,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
personality: None,
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
permission_profile: Constrained::allow_any(PermissionProfile::default()),
permission_profile: Constrained::allow_any(PermissionProfile::read_only()),
active_permission_profile: None,
network: None,
allow_login_shell: true,
@@ -261,10 +258,15 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
feedback_enabled: false,
tool_suggest: ToolSuggestConfig::default(),
otel: OtelConfig::default(),
})
};
config
.features
.set(Features::with_defaults())
.context("configure default features")?;
Ok(config)
}
async fn run_turn(thread: &CodexThread, prompt: String) -> anyhow::Result<String> {
async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyhow::Result<()> {
thread
.submit(Op::UserInput {
items: vec![UserInput::Text {
@@ -278,15 +280,62 @@ async fn run_turn(thread: &CodexThread, prompt: String) -> anyhow::Result<String
.await
.context("submit user input")?;
let mut last_agent_message = String::new();
let mut current_turn_id: Option<String> = None;
let mut stdout = std::io::stdout().lock();
loop {
let event = thread.next_event().await.context("read Codex event")?;
match event.msg {
EventMsg::TurnComplete(event) => {
return Ok(event.last_agent_message.unwrap_or(last_agent_message));
let notification = match &event.msg {
EventMsg::TurnStarted(event) => {
current_turn_id = Some(event.turn_id.clone());
None
}
EventMsg::AgentMessage(event) => {
last_agent_message = event.message;
EventMsg::DynamicToolCallResponse(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeBegin(_)
| EventMsg::CollabResumeEnd(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::PatchApplyBegin(_)
| EventMsg::PatchApplyUpdated(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandOutputDelta(_)
| EventMsg::ExecCommandEnd(_) => Some(item_event_to_server_notification(
event.msg.clone(),
thread_id,
current_turn_id
.as_deref()
.context("mapped notification arrived before turn started")?,
/*is_file_change_output*/ false,
)),
_ => None,
};
if let Some(notification) = notification {
serde_json::to_writer(&mut stdout, &notification)
.context("serialize mapped notification")?;
stdout
.write_all(b"\n")
.context("write notification newline")?;
stdout.flush().context("flush notification output")?;
}
match event.msg {
EventMsg::TurnComplete(_) => {
return Ok(());
}
EventMsg::Error(event) => {
bail!(event.message);