codex: attribute warning lifecycle origins

This commit is contained in:
Adam Perry
2026-06-10 19:08:08 -07:00
parent 9d87b771ce
commit eaf3c37c2d
27 changed files with 366 additions and 13 deletions
@@ -5208,6 +5208,15 @@
"description": "Concise warning message for the user.",
"type": "string"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/WarningSource"
}
],
"default": "runtime",
"description": "Lifecycle source that produced the warning."
},
"threadId": {
"description": "Optional thread target when the warning applies to a specific thread.",
"type": [
@@ -5221,6 +5230,31 @@
],
"type": "object"
},
"WarningSource": {
"oneOf": [
{
"description": "Warning without a more specific startup origin.",
"enum": [
"runtime"
],
"type": "string"
},
{
"description": "Warning loaded with configuration before constructing the thread.",
"enum": [
"config"
],
"type": "string"
},
{
"description": "Warning discovered while constructing a thread before its first turn.",
"enum": [
"threadStartup"
],
"type": "string"
}
]
},
"WebSearchAction": {
"oneOf": [
{
@@ -19354,6 +19354,15 @@
"description": "Concise warning message for the user.",
"type": "string"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/v2/WarningSource"
}
],
"default": "runtime",
"description": "Lifecycle source that produced the warning."
},
"threadId": {
"description": "Optional thread target when the warning applies to a specific thread.",
"type": [
@@ -19368,6 +19377,31 @@
"title": "WarningNotification",
"type": "object"
},
"WarningSource": {
"oneOf": [
{
"description": "Warning without a more specific startup origin.",
"enum": [
"runtime"
],
"type": "string"
},
{
"description": "Warning loaded with configuration before constructing the thread.",
"enum": [
"config"
],
"type": "string"
},
{
"description": "Warning discovered while constructing a thread before its first turn.",
"enum": [
"threadStartup"
],
"type": "string"
}
]
},
"WebSearchAction": {
"oneOf": [
{
@@ -17171,6 +17171,15 @@
"description": "Concise warning message for the user.",
"type": "string"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/WarningSource"
}
],
"default": "runtime",
"description": "Lifecycle source that produced the warning."
},
"threadId": {
"description": "Optional thread target when the warning applies to a specific thread.",
"type": [
@@ -17185,6 +17194,31 @@
"title": "WarningNotification",
"type": "object"
},
"WarningSource": {
"oneOf": [
{
"description": "Warning without a more specific startup origin.",
"enum": [
"runtime"
],
"type": "string"
},
{
"description": "Warning loaded with configuration before constructing the thread.",
"enum": [
"config"
],
"type": "string"
},
{
"description": "Warning discovered while constructing a thread before its first turn.",
"enum": [
"threadStartup"
],
"type": "string"
}
]
},
"WebSearchAction": {
"oneOf": [
{
@@ -1,10 +1,46 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WarningSource": {
"oneOf": [
{
"description": "Warning without a more specific startup origin.",
"enum": [
"runtime"
],
"type": "string"
},
{
"description": "Warning loaded with configuration before constructing the thread.",
"enum": [
"config"
],
"type": "string"
},
{
"description": "Warning discovered while constructing a thread before its first turn.",
"enum": [
"threadStartup"
],
"type": "string"
}
]
}
},
"properties": {
"message": {
"description": "Concise warning message for the user.",
"type": "string"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/WarningSource"
}
],
"default": "runtime",
"description": "Lifecycle source that produced the warning."
},
"threadId": {
"description": "Optional thread target when the warning applies to a specific thread.",
"type": [
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WarningSource } from "./WarningSource";
export type WarningNotification = {
/**
@@ -10,4 +11,8 @@ threadId: string | null,
/**
* Concise warning message for the user.
*/
message: string, };
message: string,
/**
* Lifecycle source that produced the warning.
*/
source: WarningSource, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WarningSource = "runtime" | "config" | "threadStartup";
@@ -469,6 +469,7 @@ export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { TurnsPage } from "./TurnsPage";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WarningSource } from "./WarningSource";
export type { WebSearchAction } from "./WebSearchAction";
export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness";
export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse";
@@ -23,6 +23,22 @@ pub struct WarningNotification {
pub thread_id: Option<String>,
/// Concise warning message for the user.
pub message: String,
/// Lifecycle source that produced the warning.
#[serde(default)]
pub source: WarningSource,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum WarningSource {
/// Warning without a more specific startup origin.
#[default]
Runtime,
/// Warning loaded with configuration before constructing the thread.
Config,
/// Warning discovered while constructing a thread before its first turn.
ThreadStartup,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+1 -1
View File
@@ -1243,7 +1243,7 @@ Thread realtime uses a separate thread-scoped notification surface. `thread/real
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
Thread-scoped warnings use the `warning` notification: `{ threadId?, message, source }`. The `source` is `config` for warnings preloaded with configuration, `threadStartup` for warnings discovered while constructing a thread, such as unreadable project instruction files, and `runtime` for warnings without a more specific startup origin, including cases where not all enabled skills are included in the model-visible skills list for a session.
### Notification opt-out
@@ -81,6 +81,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WarningNotification;
use codex_app_server_protocol::WarningSource;
use codex_app_server_protocol::build_item_from_guardian_event;
use codex_app_server_protocol::guardian_auto_approval_review_notification;
use codex_app_server_protocol::item_event_to_server_notification;
@@ -226,6 +227,27 @@ pub(crate) async fn apply_bespoke_event_handling(
let notification = WarningNotification {
thread_id: Some(conversation_id.to_string()),
message: warning_event.message,
source: WarningSource::Runtime,
};
outgoing
.send_server_notification(ServerNotification::Warning(notification))
.await;
}
EventMsg::ConfigWarning(warning_event) => {
let notification = WarningNotification {
thread_id: Some(conversation_id.to_string()),
message: warning_event.message,
source: WarningSource::Config,
};
outgoing
.send_server_notification(ServerNotification::Warning(notification))
.await;
}
EventMsg::ThreadStartupWarning(warning_event) => {
let notification = WarningNotification {
thread_id: Some(conversation_id.to_string()),
message: warning_event.message,
source: WarningSource::ThreadStartup,
};
outgoing
.send_server_notification(ServerNotification::Warning(notification))
@@ -21,6 +21,8 @@ use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::WarningNotification;
use codex_app_server_protocol::WarningSource;
use codex_config::loader::project_trust_key;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
@@ -399,6 +401,103 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re
Ok(())
}
#[tokio::test]
async fn thread_start_warning_identifies_thread_startup_source() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let workspace = TempDir::new()?;
let project_agents_path = workspace.path().join("AGENTS.md");
std::fs::write(&project_agents_path, b"project\xFFinstructions")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(response)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("warning"),
)
.await??;
let warning: WarningNotification =
serde_json::from_value(notification.params.expect("thread startup warning params"))?;
assert_eq!(
warning,
WarningNotification {
thread_id: Some(thread.id),
message: format!(
"Project AGENTS.md instructions from `{}` contain invalid UTF-8: invalid utf-8 sequence of 1 bytes from index 7. Invalid byte sequences were replaced.",
project_agents_path.display()
),
source: WarningSource::ThreadStartup,
}
);
Ok(())
}
#[tokio::test]
async fn thread_start_warning_identifies_config_source() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let global_agents_path = codex_home.path().join("AGENTS.md");
std::fs::write(&global_agents_path, b"global\xFFinstructions")?;
let global_agents_path = global_agents_path.canonicalize()?;
let workspace = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(response)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("warning"),
)
.await??;
let warning: WarningNotification =
serde_json::from_value(notification.params.expect("configuration warning params"))?;
assert_eq!(
warning,
WarningNotification {
thread_id: Some(thread.id),
message: format!(
"Global AGENTS.md instructions from `{}` contain invalid UTF-8: invalid utf-8 sequence of 1 bytes from index 6. Invalid byte sequences were replaced.",
global_agents_path.display()
),
source: WarningSource::Config,
}
);
Ok(())
}
#[tokio::test]
async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -62,6 +62,7 @@ use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WarningNotification;
use codex_app_server_protocol::WarningSource;
use codex_config::config_toml::ConfigToml;
use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME;
use codex_core::test_support::all_model_presets;
@@ -643,6 +644,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
let warning: WarningNotification =
serde_json::from_value(params).expect("deserialize warning notification");
assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str()));
assert_eq!(warning.source, WarningSource::Runtime);
assert_eq!(
warning.message,
"Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list."
+9 -2
View File
@@ -476,7 +476,7 @@ impl Codex {
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let CodexSpawnArgs {
mut config,
config,
installation_id,
auth_manager,
models_manager,
@@ -517,7 +517,6 @@ impl Codex {
} else {
None
};
config.startup_warnings.extend(user_instruction_warnings);
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
// Guardian review should rely on the built-in shell safety checks,
@@ -658,6 +657,14 @@ impl Codex {
error!("Failed to create session: {e:#}");
map_session_init_error(&e, &config.codex_home)
})?;
for message in user_instruction_warnings {
session
.send_event_raw(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::ThreadStartupWarning(WarningEvent { message }),
})
.await;
}
let thread_id = session.thread_id;
// This task will run until Op::Shutdown is received.
+3 -3
View File
@@ -711,7 +711,7 @@ impl Session {
for message in &config.startup_warnings {
post_session_configured_events.push(Event {
id: "".to_owned(),
msg: EventMsg::Warning(WarningEvent {
msg: EventMsg::ConfigWarning(WarningEvent {
message: message.clone(),
}),
});
@@ -732,7 +732,7 @@ impl Session {
if config.permissions.approval_policy.value() == AskForApproval::OnFailure {
post_session_configured_events.push(Event {
id: "".to_owned(),
msg: EventMsg::Warning(WarningEvent {
msg: EventMsg::ThreadStartupWarning(WarningEvent {
message: "`on-failure` approval policy is deprecated and will be removed in a future release. Use `on-request` for interactive approvals or `never` for non-interactive runs.".to_string(),
}),
});
@@ -922,7 +922,7 @@ impl Session {
for warning in hooks.startup_warnings() {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::Warning(WarningEvent {
msg: EventMsg::ThreadStartupWarning(WarningEvent {
message: warning.clone(),
}),
});
+2
View File
@@ -1365,6 +1365,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
},
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::ConfigWarning(_)
| EventMsg::ThreadStartupWarning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationSdp(_)
+33 -1
View File
@@ -474,7 +474,7 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()>
let mut builder = test_codex().with_home(home);
let test = builder.build(&server).await?;
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Warning(warning)
EventMsg::ConfigWarning(warning)
if warning
.message
.contains(source.as_path().display().to_string().as_str()) =>
@@ -500,6 +500,38 @@ async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn project_loading_warning_is_attributed_to_thread_startup() -> Result<()> {
let server = responses::start_mock_server().await;
let mut builder = test_codex().with_workspace_setup(|cwd, fs| async move {
fs.write_file(
&cwd.join(GLOBAL_AGENTS_FILENAME),
b"project\xFFinstructions".to_vec(),
/*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
});
let test = builder.build_with_remote_env(&server).await?;
let source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME);
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::ThreadStartupWarning(warning)
if warning
.message
.contains(source.as_path().display().to_string().as_str()) =>
{
Some(warning.message.clone())
}
_ => None,
})
.await;
assert!(warning.contains("invalid UTF-8"), "{warning}");
Ok(())
}
// TODO(anp): Align cold-resume instruction sources with the historical instructions replayed to
// the model so the API source list and model-visible context describe the same files.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -51,8 +51,11 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
.await
.expect("spawn conversation");
let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning else {
let warning = wait_for_event(&conversation, |ev| {
matches!(ev, EventMsg::ThreadStartupWarning(_))
})
.await;
let EventMsg::ThreadStartupWarning(WarningEvent { message }) = warning else {
panic!("expected warning event");
};
assert!(message.contains("child_agents_md"));
@@ -99,7 +102,9 @@ async fn suppresses_warning_when_configured() {
let warning = timeout(
Duration::from_millis(150),
wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))),
wait_for_event(&conversation, |ev| {
matches!(ev, EventMsg::ThreadStartupWarning(_))
}),
)
.await;
assert!(warning.is_err());
+1 -1
View File
@@ -1282,7 +1282,7 @@ pub fn unstable_features_warning_event(
);
Some(Event {
id: String::new(),
msg: EventMsg::Warning(WarningEvent { message }),
msg: EventMsg::ThreadStartupWarning(WarningEvent { message }),
})
}
+1 -1
View File
@@ -705,7 +705,7 @@ fn unstable_warning_event_only_mentions_enabled_under_development_features() {
)
.expect("warning event");
let EventMsg::Warning(WarningEvent { message }) = warning.msg else {
let EventMsg::ThreadStartupWarning(WarningEvent { message }) = warning.msg else {
panic!("expected warning event");
};
assert!(message.contains("child_agents_md"));
@@ -263,6 +263,8 @@ async fn run_codex_tool_session_inner(
break;
}
EventMsg::Warning(_)
| EventMsg::ConfigWarning(_)
| EventMsg::ThreadStartupWarning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_) => {
+6
View File
@@ -1167,6 +1167,12 @@ pub enum EventMsg {
/// indicates the turn continued but the user should still be notified.
Warning(WarningEvent),
/// Warning loaded with configuration before constructing the thread.
ConfigWarning(WarningEvent),
/// Warning discovered while constructing a thread, before its first turn.
ThreadStartupWarning(WarningEvent),
/// Warning issued by the guardian automatic approval reviewer.
GuardianWarning(WarningEvent),
@@ -225,6 +225,8 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTr
}),
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::ConfigWarning(_)
| EventMsg::ThreadStartupWarning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationRealtime(_)
@@ -298,6 +300,8 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s
EventMsg::ThreadRolledBack(_) => Some("thread_rolled_back"),
EventMsg::Error(_) => Some("error"),
EventMsg::Warning(_) => Some("warning"),
EventMsg::ConfigWarning(_) => Some("config_warning"),
EventMsg::ThreadStartupWarning(_) => Some("thread_startup_warning"),
EventMsg::ShutdownComplete => Some("shutdown_complete"),
EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
+2
View File
@@ -111,6 +111,8 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::Warning(_)
| EventMsg::ConfigWarning(_)
| EventMsg::ThreadStartupWarning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationSdp(_)
@@ -235,6 +235,7 @@ mod tests {
let notification = ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "warning".to_string(),
source: codex_app_server_protocol::WarningSource::Runtime,
});
let target = server_notification_thread_target(&notification);
@@ -248,6 +249,7 @@ mod tests {
let notification = ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "warning".to_string(),
source: codex_app_server_protocol::WarningSource::Runtime,
});
let target = server_notification_thread_target(&notification);
+1
View File
@@ -2343,6 +2343,7 @@ async fn replay_snapshot_with_pending_request_suppresses_replay_notices() {
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: stale_warning.to_string(),
source: codex_app_server_protocol::WarningSource::Runtime,
},
)),
ThreadBufferedEvent::Request(exec_approval_request(
@@ -411,6 +411,7 @@ async fn live_app_server_warning_notification_renders_message() {
ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
source: codex_app_server_protocol::WarningSource::Runtime,
}),
/*replay_kind*/ None,
);
@@ -466,6 +466,7 @@ pub(super) fn handle_warning(chat: &mut ChatWidget, message: impl Into<String>)
ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id(chat)),
message: message.into(),
source: codex_app_server_protocol::WarningSource::Runtime,
}),
/*replay_kind*/ None,
);