Files
codex/codex-rs/thread-manager-sample/src/main.rs
Eric Traut 49a9d78999 Make older app-server notices configurable in the TUI (#43698)
## What changed

Add `tui.show_server_version_notice`, enabled by default. Set it to `false` to suppress informational notices about older stable app servers at startup, on reconnect, and in the agents overview. Compatibility errors and version status remain unaffected.

Refresh the overview notice when local settings change, clearing pending notices when the setting is disabled.

## Testing

Add coverage for disabling and re-enabling overview notices, clearing pending notice state, and preserving remote version status. Extend reconnect and local-settings tests to cover the new setting.

GitOrigin-RevId: d50dcf4472ee221ce31cdd7ab2cd766f901a8039
2026-09-08 04:35:21 +00:00

428 lines
16 KiB
Rust

use std::collections::BTreeMap;
use std::collections::HashMap;
use std::io::IsTerminal;
use std::io::Read;
use std::io::Write;
use std::sync::Arc;
use anyhow::Context;
use anyhow::bail;
use clap::Parser;
use codex_core_api::AbsolutePathBuf;
use codex_core_api::AltScreenMode;
use codex_core_api::ApprovalsReviewer;
use codex_core_api::Arg0DispatchPaths;
use codex_core_api::AskForApproval;
use codex_core_api::AuthCredentialsStoreMode;
use codex_core_api::AuthManager;
use codex_core_api::AutoCompactTokenLimitScope;
use codex_core_api::CodexAppsToolsCache;
use codex_core_api::CodexHomeUserInstructionsProvider;
use codex_core_api::CodexThread;
use codex_core_api::Config;
use codex_core_api::ConfigLayerStack;
use codex_core_api::Constrained;
use codex_core_api::EnvironmentManager;
use codex_core_api::EventMsg;
use codex_core_api::ExecServerRuntimePaths;
use codex_core_api::ExtensionRegistryBuilder;
use codex_core_api::Features;
use codex_core_api::GhostSnapshotConfig;
use codex_core_api::History;
use codex_core_api::MemoriesConfig;
use codex_core_api::ModelAvailabilityNuxConfig;
use codex_core_api::MultiAgentV2Config;
use codex_core_api::NewThread;
use codex_core_api::Notice;
use codex_core_api::OAuthCredentialsStoreMode;
use codex_core_api::OPENAI_PROVIDER_ID;
use codex_core_api::OtelConfig;
use codex_core_api::PermissionProfile;
use codex_core_api::Permissions;
use codex_core_api::ProjectConfig;
use codex_core_api::RealtimeAudioConfig;
use codex_core_api::RealtimeConfig;
use codex_core_api::SessionPickerViewMode;
use codex_core_api::SessionSource;
use codex_core_api::SqliteConfig;
use codex_core_api::StartIfIdleSubmission;
use codex_core_api::StartThreadOptions;
use codex_core_api::TerminalResizeReflowConfig;
use codex_core_api::ThreadManager;
use codex_core_api::ThreadStoreConfig;
use codex_core_api::ToolSuggestConfig;
use codex_core_api::TuiKeymap;
use codex_core_api::TuiNotificationSettings;
use codex_core_api::TuiPetAnchor;
use codex_core_api::TurnInputRequest;
use codex_core_api::UriBasedFileOpener;
use codex_core_api::UserInput;
use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::build_models_manager;
use codex_core_api::built_in_model_providers;
use codex_core_api::find_codex_home;
use codex_core_api::init_state_db;
use codex_core_api::install_image_generation_extension;
use codex_core_api::item_event_to_server_notification;
use codex_core_api::local_agent_graph_store_from_state_db;
use codex_core_api::passthrough_image_store;
use codex_core_api::resolve_installation_id;
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 mapped notifications as newline-delimited JSON."
)]
struct Args {
/// Override the model for this run.
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Prompt text. If omitted, the prompt is read from piped stdin.
#[arg(value_name = "PROMPT", num_args = 0.., trailing_var_arg = true)]
prompt: Vec<String>,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(run_main)
}
async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
if let Err(err) = set_default_originator("codex_thread_manager_sample".to_string()) {
tracing::warn!("failed to set originator: {err:?}");
}
let args = Args::parse();
let prompt = if args.prompt.is_empty() {
if std::io::stdin().is_terminal() {
bail!("no prompt provided; pass a prompt argument or pipe one into stdin");
}
let mut prompt = String::new();
std::io::stdin()
.read_to_string(&mut prompt)
.context("read prompt from stdin")?;
let prompt = prompt.replace("\r\n", "\n").replace('\r', "\n");
if prompt.trim().is_empty() {
bail!("no prompt provided via stdin");
}
prompt
} else {
args.prompt.join(" ")
};
let config = new_config(args.model, arg0_paths)?;
let state_db = init_state_db(&config).await;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await?;
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
config.codex_self_exe.clone(),
config.codex_linux_sandbox_exe.clone(),
)?;
let thread_store = thread_store_from_config(&config, state_db.clone());
let environment_manager = Arc::new(
EnvironmentManager::from_codex_home(
config.codex_home.clone(),
Some(local_runtime_paths),
config.http_client_factory(),
)
.await?,
);
let installation_id = resolve_installation_id(&config.codex_home).await?;
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install_image_generation_extension(&mut extensions, auth_manager.clone(), |config: &Config| {
Some(config.codex_home.clone())
});
let thread_manager = ThreadManager::new(
&config,
Arc::clone(&auth_manager),
build_models_manager(&config, auth_manager),
CodexAppsToolsCache::default(),
SessionSource::Exec,
environment_manager,
Arc::new(extensions.build()),
user_instructions_provider,
/*analytics_events_client*/ None,
passthrough_image_store(),
Arc::clone(&thread_store),
local_agent_graph_store_from_state_db(state_db.as_ref()),
installation_id,
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
let NewThread {
thread_id, thread, ..
} = thread_manager
.start_thread(StartThreadOptions::new(config))
.await
.context("start Codex thread")?;
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;
turn_output?;
shutdown_result.context("shut down Codex thread")?;
Ok(())
}
fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<Config> {
let codex_home = find_codex_home().context("find Codex home")?;
let cwd = AbsolutePathBuf::current_dir().context("resolve current directory")?;
let model_provider_id = OPENAI_PROVIDER_ID.to_string();
let model_providers = built_in_model_providers(/*openai_base_url*/ None);
let model_provider = model_providers
.get(&model_provider_id)
.context("OpenAI model provider should be available")?
.clone();
let mut config = Config {
config_layer_stack: ConfigLayerStack::default(),
startup_warnings: Vec::new(),
bypass_hook_trust: false,
model,
service_tier: None,
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
model_provider_id,
model_provider,
personality: None,
permissions: Permissions::from_approval_and_profile(
Constrained::allow_any(AskForApproval::Never),
Constrained::allow_any(PermissionProfile::read_only()),
)?,
explicit_permission_profile_mode: false,
custom_permission_profiles: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
base_instructions: None,
base_instructions_provenance: None,
developer_instructions: None,
guardian_policy_config: None,
include_permissions_instructions: false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
include_skill_instructions: false,
skill_max_context_tokens: None,
orchestrator_skills_enabled: false,
orchestrator_mcp_enabled: false,
include_environment_context: false,
compact_prompt: None,
notify: None,
tui_notifications: TuiNotificationSettings::default(),
animations: true,
tui_whimsy: true,
show_tooltips: true,
tui_show_server_version_notice: true,
tui_auto_recap: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_raw_output_mode: false,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_session_picker_view: SessionPickerViewMode::Dense,
tui_resume_cwd: None,
tui_vim_mode_default: false,
tui_question_esc_back: true,
cwd: cwd.clone(),
workspace_roots: vec![cwd],
workspace_roots_explicit: false,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
mcp_servers: Constrained::allow_any(HashMap::new()),
non_prefixed_mcp_tool_servers: None,
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::File,
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
mcp_optional_startup_grace: std::time::Duration::from_secs(1),
model_providers,
project_doc_max_bytes: 32 * 1024,
project_doc_fallback_filenames: Vec::new(),
tool_output_token_limit: None,
agents_enabled: true,
agent_max_threads: Some(6),
agent_default_subagent_model: None,
agent_default_subagent_reasoning_effort: None,
agent_interrupt_message_enabled: false,
agent_max_depth: 1,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
sqlite: SqliteConfig::from_sqlite_home(codex_home.clone()),
log_dir: codex_home.join("log").to_path_buf(),
codex_home,
history: History::default(),
ephemeral: true,
extra_config: None,
file_opener: UriBasedFileOpener::VsCode,
codex_self_exe: arg0_paths.codex_self_exe,
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe,
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe,
zsh_path: None,
model_reasoning_effort: None,
plan_mode_reasoning_effort: None,
model_reasoning_summary: None,
model_catalog: None,
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
respect_system_proxy: false,
apps_mcp_product_sku: None,
responses_api_metadata: BTreeMap::new(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_ws_base_url: None,
experimental_realtime_webrtc_call_base_url: None,
experimental_realtime_ws_model: None,
realtime: RealtimeConfig::default(),
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
experimental_realtime_start_instructions: None,
experimental_thread_store: ThreadStoreConfig::Local,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Disabled),
web_search_config: None,
experimental_request_user_input_enabled: true,
update_plan_enabled: true,
tool_registry: Default::default(),
code_mode: Default::default(),
background_terminal_max_timeout: 300_000,
thread_unload_delay: std::time::Duration::from_secs(60),
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
max_goal_token_budget: None,
token_budget: None,
token_budget_startup_config: None,
rollout_budget: None,
current_time_reminder: None,
sleep_tool_mode: Default::default(),
features: Default::default(),
suppress_unstable_features_warning: false,
active_project: ProjectConfig { trust_level: None },
notices: Notice::default(),
check_for_update_on_startup: false,
disable_paste_burst: false,
analytics_enabled: Some(false),
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, thread_id: &str, prompt: String) -> anyhow::Result<()> {
let submission = thread
.start_turn_if_idle(TurnInputRequest::user_input(vec![UserInput::Text {
text: prompt,
text_elements: Vec::new(),
}]))
.await
.context("submit user input")?;
if let StartIfIdleSubmission::NotSubmitted { reason } = submission {
bail!("turn input was not submitted: {reason:?}");
}
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")?;
let notification = match &event.msg {
EventMsg::TurnStarted(event) => {
current_turn_id = Some(event.turn_id.clone());
None
}
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::SubAgentActivity(_)
| 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")?,
)),
_ => 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);
}
EventMsg::TurnAborted(_) => {
bail!("turn aborted");
}
EventMsg::ExecApprovalRequest(_) => {
bail!("turn requested exec approval");
}
EventMsg::ApplyPatchApprovalRequest(_) => {
bail!("turn requested patch approval");
}
EventMsg::RequestPermissions(_) => {
bail!("turn requested permissions");
}
EventMsg::RequestUserInput(_) => {
bail!("turn requested user input");
}
EventMsg::DynamicToolCallRequest(_) => {
bail!("turn requested a dynamic tool call");
}
_ => {}
}
}
}