Skip to content

Commit e6cd75a

Browse files
authored
notify: include client in legacy hook payload (openai#12968)
## Why The `notify` hook payload did not identify which Codex client started the turn. That meant downstream notification hooks could not distinguish between completions coming from the TUI and completions coming from app-server clients such as VS Code or Xcode. Now that the Codex App provides its own desktop notifications, it would be nice to be able to filter those out. This change adds that context without changing the existing payload shape for callers that do not know the client name, and keeps the new end-to-end test cross-platform. ## What changed - added an optional top-level `client` field to the legacy `notify` JSON payload - threaded that value through `core` and `hooks`; the internal session and turn state now carries it as `app_server_client_name` - set the field to `codex-tui` for TUI turns - captured `initialize.clientInfo.name` in the app server and applied it to subsequent turns before dispatching hooks - replaced the notify integration test hook with a `python3` script so the test does not rely on Unix shell permissions or `bash` - documented the new field in `docs/config.md` ## Testing - `cargo test -p codex-hooks` - `cargo test -p codex-tui` - `cargo test -p codex-app-server suite::v2::initialize::turn_start_notify_payload_includes_initialize_client_name -- --exact --nocapture` - `cargo test -p codex-core` (`src/lib.rs` passed; `core/tests/all.rs` still has unrelated existing failures in this environment) ## Docs The public config reference on `developers.openai.com/codex` should mention that the legacy `notify` payload may include a top-level `client` field. The TUI reports `codex-tui`, and the app server reports `initialize.clientInfo.name` when it is available.
1 parent 53e28f1 commit e6cd75a

File tree

11 files changed

+266
-25
lines changed

11 files changed

+266
-25
lines changed

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

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,12 @@ impl CodexMessageProcessor {
560560
Ok((review_request, hint))
561561
}
562562

563-
pub async fn process_request(&mut self, connection_id: ConnectionId, request: ClientRequest) {
563+
pub async fn process_request(
564+
&mut self,
565+
connection_id: ConnectionId,
566+
request: ClientRequest,
567+
app_server_client_name: Option<String>,
568+
) {
564569
let to_connection_request_id = |request_id| ConnectionRequestId {
565570
connection_id,
566571
request_id,
@@ -647,8 +652,12 @@ impl CodexMessageProcessor {
647652
.await;
648653
}
649654
ClientRequest::TurnStart { request_id, params } => {
650-
self.turn_start(to_connection_request_id(request_id), params)
651-
.await;
655+
self.turn_start(
656+
to_connection_request_id(request_id),
657+
params,
658+
app_server_client_name.clone(),
659+
)
660+
.await;
652661
}
653662
ClientRequest::TurnSteer { request_id, params } => {
654663
self.turn_steer(to_connection_request_id(request_id), params)
@@ -767,12 +776,20 @@ impl CodexMessageProcessor {
767776
.await;
768777
}
769778
ClientRequest::SendUserMessage { request_id, params } => {
770-
self.send_user_message(to_connection_request_id(request_id), params)
771-
.await;
779+
self.send_user_message(
780+
to_connection_request_id(request_id),
781+
params,
782+
app_server_client_name.clone(),
783+
)
784+
.await;
772785
}
773786
ClientRequest::SendUserTurn { request_id, params } => {
774-
self.send_user_turn(to_connection_request_id(request_id), params)
775-
.await;
787+
self.send_user_turn(
788+
to_connection_request_id(request_id),
789+
params,
790+
app_server_client_name.clone(),
791+
)
792+
.await;
776793
}
777794
ClientRequest::InterruptConversation { request_id, params } => {
778795
self.interrupt_conversation(to_connection_request_id(request_id), params)
@@ -5063,6 +5080,7 @@ impl CodexMessageProcessor {
50635080
&self,
50645081
request_id: ConnectionRequestId,
50655082
params: SendUserMessageParams,
5083+
app_server_client_name: Option<String>,
50665084
) {
50675085
let SendUserMessageParams {
50685086
conversation_id,
@@ -5081,6 +5099,12 @@ impl CodexMessageProcessor {
50815099
self.outgoing.send_error(request_id, error).await;
50825100
return;
50835101
};
5102+
if let Err(error) =
5103+
Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await
5104+
{
5105+
self.outgoing.send_error(request_id, error).await;
5106+
return;
5107+
}
50845108

50855109
let mapped_items: Vec<CoreInputItem> = items
50865110
.into_iter()
@@ -5111,7 +5135,12 @@ impl CodexMessageProcessor {
51115135
.await;
51125136
}
51135137

5114-
async fn send_user_turn(&self, request_id: ConnectionRequestId, params: SendUserTurnParams) {
5138+
async fn send_user_turn(
5139+
&self,
5140+
request_id: ConnectionRequestId,
5141+
params: SendUserTurnParams,
5142+
app_server_client_name: Option<String>,
5143+
) {
51155144
let SendUserTurnParams {
51165145
conversation_id,
51175146
items,
@@ -5137,6 +5166,12 @@ impl CodexMessageProcessor {
51375166
self.outgoing.send_error(request_id, error).await;
51385167
return;
51395168
};
5169+
if let Err(error) =
5170+
Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await
5171+
{
5172+
self.outgoing.send_error(request_id, error).await;
5173+
return;
5174+
}
51405175

51415176
let mapped_items: Vec<CoreInputItem> = items
51425177
.into_iter()
@@ -5638,7 +5673,12 @@ impl CodexMessageProcessor {
56385673
let _ = conversation.submit(Op::Interrupt).await;
56395674
}
56405675

5641-
async fn turn_start(&self, request_id: ConnectionRequestId, params: TurnStartParams) {
5676+
async fn turn_start(
5677+
&self,
5678+
request_id: ConnectionRequestId,
5679+
params: TurnStartParams,
5680+
app_server_client_name: Option<String>,
5681+
) {
56425682
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
56435683
self.outgoing.send_error(request_id, error).await;
56445684
return;
@@ -5650,6 +5690,12 @@ impl CodexMessageProcessor {
56505690
return;
56515691
}
56525692
};
5693+
if let Err(error) =
5694+
Self::set_app_server_client_name(thread.as_ref(), app_server_client_name).await
5695+
{
5696+
self.outgoing.send_error(request_id, error).await;
5697+
return;
5698+
}
56535699

56545700
let collaboration_modes_config = CollaborationModesConfig {
56555701
default_mode_request_user_input: thread.enabled(Feature::DefaultModeRequestUserInput),
@@ -5731,6 +5777,20 @@ impl CodexMessageProcessor {
57315777
}
57325778
}
57335779

5780+
async fn set_app_server_client_name(
5781+
thread: &CodexThread,
5782+
app_server_client_name: Option<String>,
5783+
) -> Result<(), JSONRPCErrorError> {
5784+
thread
5785+
.set_app_server_client_name(app_server_client_name)
5786+
.await
5787+
.map_err(|err| JSONRPCErrorError {
5788+
code: INTERNAL_ERROR_CODE,
5789+
message: format!("failed to set app server client name: {err}"),
5790+
data: None,
5791+
})
5792+
}
5793+
57345794
async fn turn_steer(&self, request_id: ConnectionRequestId, params: TurnSteerParams) {
57355795
let (_, thread) = match self.load_thread(&params.thread_id).await {
57365796
Ok(v) => v,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pub(crate) struct ConnectionSessionState {
140140
pub(crate) initialized: bool,
141141
pub(crate) experimental_api_enabled: bool,
142142
pub(crate) opted_out_notification_methods: HashSet<String>,
143+
pub(crate) app_server_client_name: Option<String>,
143144
}
144145

145146
pub(crate) struct MessageProcessorArgs {
@@ -329,6 +330,7 @@ impl MessageProcessor {
329330
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
330331
*suffix = Some(user_agent_suffix);
331332
}
333+
session.app_server_client_name = Some(name.clone());
332334

333335
let user_agent = get_codex_user_agent();
334336
let response = InitializeResponse { user_agent };
@@ -430,7 +432,7 @@ impl MessageProcessor {
430432
// inline the full `CodexMessageProcessor::process_request` future, which
431433
// can otherwise push worker-thread stack usage over the edge.
432434
self.codex_message_processor
433-
.process_request(connection_id, other)
435+
.process_request(connection_id, other, session.app_server_client_name.clone())
434436
.boxed()
435437
.await;
436438
}

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
use anyhow::Result;
22
use app_test_support::McpProcess;
3+
use app_test_support::create_final_assistant_message_sse_response;
34
use app_test_support::create_mock_responses_server_sequence_unchecked;
45
use app_test_support::to_response;
56
use codex_app_server_protocol::ClientInfo;
67
use codex_app_server_protocol::InitializeCapabilities;
78
use codex_app_server_protocol::InitializeResponse;
89
use codex_app_server_protocol::JSONRPCMessage;
10+
use codex_app_server_protocol::JSONRPCResponse;
911
use codex_app_server_protocol::RequestId;
1012
use codex_app_server_protocol::ThreadStartParams;
1113
use codex_app_server_protocol::ThreadStartResponse;
14+
use codex_app_server_protocol::TurnStartParams;
15+
use codex_app_server_protocol::TurnStartResponse;
16+
use codex_app_server_protocol::UserInput as V2UserInput;
17+
use core_test_support::fs_wait;
1218
use pretty_assertions::assert_eq;
19+
use serde_json::Value;
1320
use std::path::Path;
21+
use std::time::Duration;
1422
use tempfile::TempDir;
1523
use tokio::time::timeout;
1624

@@ -178,11 +186,100 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
178186
Ok(())
179187
}
180188

189+
#[tokio::test]
190+
async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<()> {
191+
let responses = vec![create_final_assistant_message_sse_response("Done")?];
192+
let server = create_mock_responses_server_sequence_unchecked(responses).await;
193+
let codex_home = TempDir::new()?;
194+
let notify_script = codex_home.path().join("notify.py");
195+
std::fs::write(
196+
&notify_script,
197+
r#"from pathlib import Path
198+
import sys
199+
200+
Path(__file__).with_name("notify.json").write_text(sys.argv[-1], encoding="utf-8")
201+
"#,
202+
)?;
203+
let notify_file = codex_home.path().join("notify.json");
204+
let notify_script = notify_script
205+
.to_str()
206+
.expect("notify script path should be valid UTF-8");
207+
create_config_toml_with_extra(
208+
codex_home.path(),
209+
&server.uri(),
210+
"never",
211+
&format!(
212+
"notify = [\"python3\", {}]",
213+
toml_basic_string(notify_script)
214+
),
215+
)?;
216+
217+
let mut mcp = McpProcess::new(codex_home.path()).await?;
218+
timeout(
219+
DEFAULT_READ_TIMEOUT,
220+
mcp.initialize_with_client_info(ClientInfo {
221+
name: "xcode".to_string(),
222+
title: Some("Xcode".to_string()),
223+
version: "1.0.0".to_string(),
224+
}),
225+
)
226+
.await??;
227+
228+
let thread_req = mcp
229+
.send_thread_start_request(ThreadStartParams::default())
230+
.await?;
231+
let thread_resp: JSONRPCResponse = timeout(
232+
DEFAULT_READ_TIMEOUT,
233+
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
234+
)
235+
.await??;
236+
let ThreadStartResponse { thread, .. } = to_response(thread_resp)?;
237+
238+
let turn_req = mcp
239+
.send_turn_start_request(TurnStartParams {
240+
thread_id: thread.id,
241+
input: vec![V2UserInput::Text {
242+
text: "Hello".to_string(),
243+
text_elements: Vec::new(),
244+
}],
245+
..Default::default()
246+
})
247+
.await?;
248+
let turn_resp: JSONRPCResponse = timeout(
249+
DEFAULT_READ_TIMEOUT,
250+
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
251+
)
252+
.await??;
253+
let _: TurnStartResponse = to_response(turn_resp)?;
254+
255+
timeout(
256+
DEFAULT_READ_TIMEOUT,
257+
mcp.read_stream_until_notification_message("turn/completed"),
258+
)
259+
.await??;
260+
261+
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).await?;
262+
let payload_raw = tokio::fs::read_to_string(&notify_file).await?;
263+
let payload: Value = serde_json::from_str(&payload_raw)?;
264+
assert_eq!(payload["client"], "xcode");
265+
266+
Ok(())
267+
}
268+
181269
// Helper to create a config.toml pointing at the mock model server.
182270
fn create_config_toml(
183271
codex_home: &Path,
184272
server_uri: &str,
185273
approval_policy: &str,
274+
) -> std::io::Result<()> {
275+
create_config_toml_with_extra(codex_home, server_uri, approval_policy, "")
276+
}
277+
278+
fn create_config_toml_with_extra(
279+
codex_home: &Path,
280+
server_uri: &str,
281+
approval_policy: &str,
282+
extra: &str,
186283
) -> std::io::Result<()> {
187284
let config_toml = codex_home.join("config.toml");
188285
std::fs::write(
@@ -195,6 +292,8 @@ sandbox_mode = "read-only"
195292
196293
model_provider = "mock_provider"
197294
295+
{extra}
296+
198297
[model_providers.mock_provider]
199298
name = "Mock provider for test"
200299
base_url = "{server_uri}/v1"
@@ -205,3 +304,7 @@ stream_max_retries = 0
205304
),
206305
)
207306
}
307+
308+
fn toml_basic_string(value: &str) -> String {
309+
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
310+
}

0 commit comments

Comments
 (0)