diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f4448d934d..1ccd781ebf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -976,6 +976,9 @@ "collaboration_modes": { "type": "boolean" }, + "command_sessions_world_state": { + "type": "boolean" + }, "compaction_image_budget": { "type": "boolean" }, @@ -7914,6 +7917,9 @@ "collaboration_modes": { "type": "boolean" }, + "command_sessions_world_state": { + "type": "boolean" + }, "compaction_image_budget": { "type": "boolean" }, diff --git a/codex-rs/core/src/context/world_state/command_sessions.rs b/codex-rs/core/src/context/world_state/command_sessions.rs new file mode 100644 index 0000000000..b7e662a821 --- /dev/null +++ b/codex-rs/core/src/context/world_state/command_sessions.rs @@ -0,0 +1,150 @@ +//! Bounded command snapshots appended only when state changes. +//! Live process state is authoritative; persisted snapshots only suppress duplicate updates. + +use super::PreviousSectionState; +use super::SectionTransition; +use super::WorldStateSection; +use super::WorldStateUpdate; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ContentItemKind; +use codex_protocol::models::input_text; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +const MAX_SESSIONS: usize = 16; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) enum CommandSessionStatus { + Running, + Exited, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct CommandSession { + pub(crate) session_id: i32, + pub(crate) status: CommandSessionStatus, + pub(crate) in_use: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct CommandSessionsState { + sessions: BTreeMap, + omitted: usize, + #[serde(default)] + pub(crate) polling_unavailable: bool, +} + +impl CommandSessionsState { + pub(crate) fn new(sessions: impl Iterator) -> Self { + let mut state = Self::default(); + for session in sessions { + state.sessions.insert(session.session_id, session); + if state.sessions.len() > MAX_SESSIONS { + state.sessions.pop_last(); + state.omitted += 1; + } + } + state + } +} + +impl WorldStateSection for CommandSessionsState { + const ID: &'static str = "command_sessions"; + type Snapshot = Self; + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + role == "developer" && Self::matches_text(text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> SectionTransition { + if matches!(previous, PreviousSectionState::Known(previous) if previous == self) { + return (None, Vec::new()); + } + ( + Some(self.clone()), + vec![WorldStateUpdate::fragment(self.clone())], + ) + } +} + +/// Clears stale handles when disabled, or preserves live handles when polling is unavailable. +#[derive(Default)] +pub(crate) struct DisabledCommandSessionsState(pub(crate) CommandSessionsState); + +impl WorldStateSection for DisabledCommandSessionsState { + const ID: &'static str = CommandSessionsState::ID; + type Snapshot = CommandSessionsState; + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + CommandSessionsState::matches_retained_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> SectionTransition { + match previous { + PreviousSectionState::Absent if self.0.sessions.is_empty() => (None, Vec::new()), + PreviousSectionState::Absent + | PreviousSectionState::Unknown + | PreviousSectionState::Known(_) => self.0.render_diff(previous), + } + } +} + +impl ContextualUserFragment for CommandSessionsState { + fn role(&self) -> &'static str { + "developer" + } + + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("exec.command_sessions".to_string()) + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> ContentItem { + // Only harness-assigned IDs and fixed lifecycle labels belong in this + // developer fragment. At most 16 entries keep it below 2 KiB. + let mut text = String::from( + "Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results for write_stdin. In-use sessions are temporarily busy with a tool interaction or result finalization.\n", + ); + if self.polling_unavailable { + text.push_str("write_stdin is unavailable with the current tool set.\n"); + } + if self.sessions.is_empty() && self.omitted == 0 { + text.push_str("None."); + } + for session in self.sessions.values() { + let id = session.session_id; + let status = match session.status { + CommandSessionStatus::Running => "running", + CommandSessionStatus::Exited => "exited; poll to collect", + }; + let in_use = if session.in_use { "; in use" } else { "" }; + text.push_str(&format!("{id}: {status}{in_use}\n")); + } + if self.omitted > 0 { + let omitted = self.omitted; + text.push_str(&format!( + "{omitted} additional sessions omitted; this snapshot is incomplete." + )); + } + input_text(format!("\n{}\n", text.trim_end())) + } +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index c83cce0979..8fb392b961 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -1,6 +1,7 @@ mod agents_md; mod apps_instructions; mod collaboration_mode; +mod command_sessions; mod compact_permissions; mod context_window_guidance; mod environment; @@ -36,12 +37,17 @@ use serde_json::Value; use sha1::Digest; use sha1::Sha1; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::fmt; use std::sync::Arc; pub(crate) use agents_md::AgentsMdState; pub(crate) use apps_instructions::AppsInstructionsState; pub(crate) use collaboration_mode::CollaborationModeState; +pub(crate) use command_sessions::CommandSession; +pub(crate) use command_sessions::CommandSessionStatus; +pub(crate) use command_sessions::CommandSessionsState; +pub(crate) use command_sessions::DisabledCommandSessionsState; pub(crate) use compact_permissions::CompactPermissionsState; pub(crate) use context_window_guidance::ContextWindowGuidanceState; pub(crate) use environment::EnvironmentsState; @@ -432,6 +438,9 @@ pub(crate) struct WorldState { #[serde(transparent)] pub(crate) struct WorldStateSnapshot { sections: BTreeMap, + // Rendering may need a refresh while persistence still needs the exact prior state. + #[serde(skip)] + invalidated_sections: BTreeSet, } impl From<&Map> for WorldStateSnapshot { @@ -441,6 +450,7 @@ impl From<&Map> for WorldStateSnapshot { .iter() .map(|(key, value)| (key.clone(), value.clone())) .collect(), + invalidated_sections: BTreeSet::new(), } } } @@ -448,6 +458,11 @@ impl From<&Map> for WorldStateSnapshot { impl WorldStateSnapshot { pub(crate) fn remove_section(&mut self, id: &str) { self.sections.remove(id); + self.invalidated_sections.remove(id); + } + + pub(crate) fn invalidate_section(&mut self, id: &str) { + self.invalidated_sections.insert(id.to_string()); } pub(crate) fn into_object(self) -> Map { @@ -455,6 +470,9 @@ impl WorldStateSnapshot { } fn section(&self, id: &str) -> Option<&Value> { + if self.invalidated_sections.contains(id) { + return None; + } let snapshot = self.sections.get(id)?; if id == PluginsInstructionsState::ID && snapshot == &Value::Bool(false) @@ -506,6 +524,7 @@ impl WorldStateSnapshot { pub(crate) fn apply_merge_patch(&mut self, patch: &Map) { // Borrow existing keys; only newly inserted sections need owned keys. for (key, value) in patch { + self.invalidated_sections.remove(key); if value.is_null() { self.sections.remove(key); } else if let Some(current) = self.sections.get_mut(key) { diff --git a/codex-rs/core/src/context/world_state/world_state_tests.rs b/codex-rs/core/src/context/world_state/world_state_tests.rs index 20c995d9d2..34b889b146 100644 --- a/codex-rs/core/src/context/world_state/world_state_tests.rs +++ b/codex-rs/core/src/context/world_state/world_state_tests.rs @@ -202,6 +202,7 @@ fn extension_owned_section_uses_its_snapshot_and_renderer() { )); let previous = WorldStateSnapshot { sections: BTreeMap::from([("extension_test".to_string(), json!({"value": "before"}))]), + ..Default::default() }; let rendered = world_state @@ -331,6 +332,7 @@ fn missing_retained_fragment_is_rendered_again() { fn extension_snapshot_updates_preserve_skipped_state() { let previous = WorldStateSnapshot { sections: BTreeMap::from([("extension_test".to_string(), json!({"published": true}))]), + ..Default::default() }; let mut removed = WorldState::default(); removed.add_extension_section(WorldStateSectionContribution::new("extension_test", |_| { @@ -367,6 +369,7 @@ fn unreadable_section_snapshot_is_treated_as_unknown() { }); let previous = WorldStateSnapshot { sections: BTreeMap::from([("test".to_string(), json!({"invalid": true}))]), + ..Default::default() }; let rendered = current.render_history_fragment_diff(Some(&previous), &[]).1; @@ -403,12 +406,14 @@ fn snapshot_merge_patch_changes_and_removes_nested_values() { ), ("removed_section".to_string(), json!({"value": true})), ]), + ..Default::default() }; let current = WorldStateSnapshot { sections: BTreeMap::from([( "kept".to_string(), json!({"same": true, "changed": "after"}), )]), + ..Default::default() }; assert_eq!( diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1997508e72..423910fe89 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2033,7 +2033,9 @@ impl Session { state.last_started_turn_id = last_started_turn_id; state.turn_attribution = turn_attribution; state.restored_turn_routing = restored_turn_routing; - if let Some(world_state) = world_state_baseline { + if let Some(mut world_state) = world_state_baseline { + // Handles belong to this runtime; a saved checkpoint can also lag its fragment. + world_state.invalidate_section("command_sessions"); state.history.set_world_state_baseline(world_state); } let fallback_ids = state.auto_compact_window_ids(); diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 13204bfa41..ad0c2eb9e3 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -171,6 +171,29 @@ impl Session { String::new() }; let mut world_state = WorldState::default(); + if turn_context + .config + .features + .enabled(Feature::CommandSessionsWorldState) + { + let mut sessions = + crate::unified_exec::command_sessions_state(&self.services.unified_exec_manager) + .await; + if step_context + .tool_router + .exposes_tool(&ToolName::plain("write_stdin")) + { + world_state.add_section(sessions); + } else { + sessions.polling_unavailable = true; + world_state.add_section(crate::context::world_state::DisabledCommandSessionsState( + sessions, + )); + } + } else { + world_state + .add_section(crate::context::world_state::DisabledCommandSessionsState::default()); + } let extension_metrics = super::extension_metrics::from_session_telemetry( step_context.session_telemetry.clone(), ); diff --git a/codex-rs/core/src/unified_exec/command_sessions.rs b/codex-rs/core/src/unified_exec/command_sessions.rs new file mode 100644 index 0000000000..6069250054 --- /dev/null +++ b/codex-rs/core/src/unified_exec/command_sessions.rs @@ -0,0 +1,29 @@ +//! Read-only snapshots of retained command sessions, including uncollected exits. +//! Handles stay private until their initial exec_command collection finishes. + +use super::UnifiedExecProcessManager; +use crate::context::world_state::CommandSession; +use crate::context::world_state::CommandSessionStatus; +use crate::context::world_state::CommandSessionsState; +use std::sync::atomic::Ordering; + +pub(crate) async fn command_sessions_state( + manager: &UnifiedExecProcessManager, +) -> CommandSessionsState { + let store = manager.process_store.lock().await; + CommandSessionsState::new( + store + .processes + .values() + .filter(|entry| !entry.initial_exec_command_active.load(Ordering::Acquire)) + .map(|entry| CommandSession { + session_id: entry.process_id, + status: if entry.process.has_exited() { + CommandSessionStatus::Exited + } else { + CommandSessionStatus::Running + }, + in_use: entry.process.interaction_lock().try_lock_owned().is_err(), + }), + ) +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 78ec3c5d90..466af4bd86 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -44,6 +44,7 @@ use crate::tools::network_approval::DeferredNetworkApproval; use codex_core_plugins::PluginMetricsSidecar; mod async_watcher; +mod command_sessions; mod errors; mod oneshot; pub(crate) mod output; @@ -58,6 +59,7 @@ pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) { process_manager::set_deterministic_process_ids_for_tests(enabled); } +pub(crate) use command_sessions::command_sessions_state; pub(crate) use errors::UnifiedExecError; pub(crate) use process::UnifiedExecProcess; pub(crate) use stdin_approval::TerminalPermissions; diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 51cf1e0d31..94458eef49 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -148,6 +148,9 @@ mod context_management_tools; #[path = "scenarios_code_mode_settled_helpers_tests.rs"] mod code_mode_settled_helpers; +#[path = "scenarios_command_sessions_tests.rs"] +mod command_sessions; + #[path = "scenarios_strict_3p_cache.rs"] mod strict_3p_cache; diff --git a/codex-rs/core/tests/suite/scenarios_command_sessions_tests.rs b/codex-rs/core/tests/suite/scenarios_command_sessions_tests.rs new file mode 100644 index 0000000000..21496b6e9b --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_command_sessions_tests.rs @@ -0,0 +1,190 @@ +//! End-to-end command lifecycle with a snapshot of its complete model-visible history. + +use super::*; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::protocol::ThreadSettingsOverrides; +use core_test_support::responses; +use core_test_support::test_codex::TestCodexHarness; +use pretty_assertions::assert_eq; + +// The watcher does not yield after sending ExecCommandEnd. A single worker lets it +// drop the interaction guard before this task resumes and captures another snapshot. +#[tokio::test] +async fn command_sessions_world_state_tracks_retained_processes() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "PowerShell execution through Wine is unavailable"); + let builder = || { + test_codex() + .with_model("gpt-6-astra") + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + configure_scenario_catalog(config); + let catalog = config + .model_catalog + .as_mut() + .expect("scenario model catalog"); + let mut no_polling = catalog + .models + .iter() + .find(|model| model.slug == "gpt-6-astra") + .expect("scenario model") + .clone(); + no_polling.slug = "no-command-polling".to_string(); + no_polling.shell_type = ConfigShellToolType::Disabled; + catalog.models.push(no_polling); + for feature in [ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::CodeModeHost, + Feature::CommandSessionsWorldState, + ] { + config + .features + .enable(feature) + .expect("enable scenario feature"); + } + }) + }; + let harness = TestCodexHarness::with_auto_env_builder(builder()).await?; + let (server, test) = (harness.server(), harness.test()); + let command = match core_test_support::test_target_os() { + core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => { + "while [ ! -f release-command ]; do sleep 0.02; done; printf finished; exit 7" + } + core_test_support::TestTargetOs::Windows => { + "while (-not (Test-Path -LiteralPath 'release-command')) { Start-Sleep -Milliseconds 20 }; [Console]::Out.Write('finished'); exit 7" + } + }; + let command_json = serde_json::to_string(command)?; + let script = format!( + r#"// @exec: {{"yield_time_ms": 30000}} +const result = await tools.exec_command({{cmd: {command_json}, login: false, yield_time_ms: 250}}); +text(result.output);"# + ); + let _mock = mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_custom_tool_call("start", "exec", &script), + ev_completed("start"), + ]), + sse(vec![ev_completed("started")]), + sse(vec![ev_completed("unchanged")]), + sse(vec![ev_completed("polling-unavailable")]), + sse(vec![ + json!({"type": "response.output_item.done", "item": { + "type": "compaction", "encrypted_content": "COMMAND_CHECKPOINT" + }}), + ev_completed("compact"), + ]), + sse(vec![ev_completed("after-compact")]), + sse(vec![ev_completed("exited")]), + ], + ) + .await; + test.submit_turn("Start the command and print only its output.") + .await?; + let terminals = test.codex.list_background_terminals().await; + assert_eq!(terminals.len(), 1); + let id = &terminals[0].process_id; + test.submit_turn("Keep the command running.").await?; + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some("no-command-polling".to_string()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("Check the command while polling is unavailable.") + .await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + assert!(!matches!(event, EventMsg::Error(_)), "{event:?}"); + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + // submit_turn restores the configured model, including write_stdin. + test.submit_turn("Check the command after compaction with polling restored.") + .await?; + harness.write_file("release-command", "done").await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ExecCommandEnd(event) if event.process_id.as_ref() == Some(id)) + }).await; + test.submit_turn("Check the command after it exits.") + .await?; + + let drain = mount_sse_sequence(server, vec![ + sse(vec![ev_custom_tool_call("drain", "exec", &format!( + "const result = await tools.write_stdin({{session_id: {id}, chars: \"\"}}); text({{output: result.output, exit_code: result.exit_code}});" + )), ev_completed("drain")]), + sse(vec![ev_completed("done")]), + ]).await; + test.submit_turn("Collect the final command result.") + .await?; + assert!(test.codex.list_background_terminals().await.is_empty()); + let output = super::super::code_mode::custom_tool_output_last_non_empty_text( + &drain.last_request().expect("drained command request"), + "drain", + ) + .expect("final command result"); + assert_eq!( + serde_json::from_str::(&output)?, + json!({"output": "finished", "exit_code": 7}) + ); + + // Persist a live session, then restart without inheriting its process manager. + let _restart = mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_custom_tool_call( + "restart-start", + "exec", + &script.replace("release-command", "release-second-command"), + ), + ev_completed("restart-start"), + ]), + sse(vec![ev_completed("restart-running")]), + sse(vec![ev_completed("restarted")]), + ], + ) + .await; + test.submit_turn("Start another command before restarting.") + .await?; + let terminals = test.codex.list_background_terminals().await; + assert_eq!(terminals.len(), 1); + let restarted_id = &terminals[0].process_id; + let resumed = builder().restart_with_auto_env(server, test).await?; + resumed + .submit_turn("Check command sessions after restarting.") + .await?; + assert!(resumed.codex.list_background_terminals().await.is_empty()); + resumed.codex.shutdown_and_wait().await?; + let rollout = fs::read_to_string(resumed.codex.rollout_path().expect("resumed rollout"))? + .lines() + .map(codex_rollout::parse_rollout_line) + .collect::, _>>()?; + let cleared = rollout + .iter() + .rev() + .find_map(|line| match &line.item { + RolloutItem::WorldState(state) => state.state.get("command_sessions"), + _ => None, + }) + .expect("command session checkpoint"); + assert_eq!(cleared, &json!({"sessions": {restarted_id: null}})); + + // Command text is platform-specific; lifecycle assertions run on every native executor. + if core_test_support::test_target_os() != core_test_support::TestTargetOs::Windows { + insta::assert_snapshot!( + "command_sessions_world_state", + context_snapshot::format_request_history_snapshot( + "A Code Mode script drops its command handle. WorldState retains it while polling is unavailable, restores it after compaction, preserves exited results, clears drained sessions, and deletes stale handles after restart.", + &responses::received_responses_requests(server).await, + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + } + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__command_sessions__command_sessions_world_state.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__command_sessions__command_sessions_world_state.snap new file mode 100644 index 0000000000..5fa04be952 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__command_sessions__command_sessions_world_state.snap @@ -0,0 +1,237 @@ +--- +source: core/tests/suite/scenarios_command_sessions_tests.rs +expression: "context_snapshot::format_request_history_snapshot(\"A Code Mode script drops its command handle. WorldState retains it while polling is unavailable, restores it after compaction, preserves exited results, clears drained sessions, and deletes stale handles after restart.\",\n&responses::received_responses_requests(server).await,\n&ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: A Code Mode script drops its command handle. WorldState retains it while polling is unavailable, restores it after compaction, preserves exited results, clears drained sessions, and deletes stale handles after restart. + +## Window 1 +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=a89029a036f4c860): + - namespace/functions; hash=03340277aa23936f + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=97cc486e5cf7761a + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[3]: + [01] + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + None. + + [02] + [03] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:message/user: + Start the command and print only its output. +07:configuration_update:reasoning={"effort":"low"} +-- request 2 (turn) -- +08:custom_tool_call/exec:// @exec: {"yield_time_ms": 30000} + const result = await tools.exec_command({cmd: "while [ ! -f release-command ]; do sleep 0.02; done; prin...inished; exit 7", login: false, yield_time_ms: 250}); [hash=836937cabebedcb5] + text(result.output); +09:custom_tool_call_output:Script completed + Wall time seconds + Output: + +10:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + 1000: running + +-- request 3 (turn) -- +11:message/user: + Keep the command running. + +## Window 2 (after request 3: settings changed (model)) +-- request 4 (turn) -- +00:additional_tools/developer (3; hash=a89029a036f4c860): + - namespace/functions; hash=03340277aa23936f + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=97cc486e5cf7761a + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[3]: + [01] + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + None. + + [02] + [03] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:message/user: + Start the command and print only its output. +07:configuration_update:reasoning={"effort":"low"} +08:custom_tool_call/exec:// @exec: {"yield_time_ms": 30000} + const result = await tools.exec_command({cmd: "while [ ! -f release-command ]; do sleep 0.02; done; prin...inished; exit 7", login: false, yield_time_ms: 250}); [hash=836937cabebedcb5] + text(result.output); +09:custom_tool_call_output:Script completed + Wall time seconds + Output: + +10:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + 1000: running + +11:message/user: + Keep the command running. +12:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + write_stdin is unavailable with the current tool set. + 1000: running + +13:message/developer: + This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c] +14:additional_tools/developer (1; hash=2a7a524a5c95d741): + - namespace/functions; hash=429e5891e0f9c86b + - custom/exec +15:message/developer: + + The current model is no-command-polling. + +16:message/developer: + +17:message/user: + Check the command while polling is unavailable. +18:configuration_update:reasoning={"effort":"low"} +-- request 5 (compaction) -- +19:compaction_trigger + +## Window 3 (after request 5: input diverged at item 00, settings changed (model)) +-- request 6 (turn) -- +00:additional_tools/developer (3; hash=4c816105a4c5511e): + - namespace/functions; hash=f6f779209c4af0f3 + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=97cc486e5cf7761a + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/user: + Start the command and print only its output. +03:message/user: + Keep the command running. +04:message/developer[3]: + [01] + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + write_stdin is unavailable with the current tool set. + 1000: running + + [02] + [03] +05:message/developer: + +06:message/developer: + +07:message/user: + +08:message/user: + Check the command while polling is unavailable. +09:compaction:encrypted=true; chars=18; hash=d39d579de36e4e75 +10:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + 1000: running + +11:message/developer: + This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c] +12:additional_tools/developer (1; hash=b35547cfcd768d37): + - namespace/functions; hash=73138b3f4a7eafaf + - custom/exec +13:message/developer: + + The current model is gpt-6-astra. + +14:message/developer: + +15:message/user: + Check the command after compaction with polling restored. +-- request 7 (turn) -- +16:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + 1000: exited; poll to collect + +17:message/user: + Check the command after it exits. +-- request 8 (turn) -- +18:message/user: + Collect the final command result. +-- request 9 (turn) -- +19:custom_tool_call/exec:const result = await tools.write_stdin({session_id: 1000, chars: ""}); text({output: result.output, exit_code: result.exit_code}); +20:custom_tool_call_output:Script completed + Wall time seconds + Output: + | {"output":"finished","exit_code":7} +21:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + None. + +-- request 10 (turn) -- +22:message/user: + Start another command before restarting. +-- request 11 (turn) -- +23:custom_tool_call/exec:// @exec: {"yield_time_ms": 30000} + const result = await tools.exec_command({cmd: "while [ ! -f release-second-command ]; do sleep 0.02; don...inished; exit 7", login: false, yield_time_ms: 250}); [hash=f914f064e7e2bfce] + text(result.output); +24:custom_tool_call_output:Script completed + Wall time seconds + Output: + +25:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + 1001: running + +-- request 12 (turn) -- +26:message/developer: + + Current command sessions for this thread (supersedes earlier snapshots). Exited sessions retain results ... busy with a tool interaction or result finalization. [hash=e247ea1c0c96e976] + None. + +27:message/user: + +28:message/user: + Check command sessions after restarting. +29:configuration_update:reasoning={"effort":"low"} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index bbea25cf5c..e572498eeb 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -302,6 +302,8 @@ pub enum Feature { ToolSearchAlwaysDeferMcpTools, /// Describe deferred tool namespaces in the model-visible world state. DeferredToolWorldState, + /// Publish retained command sessions through incremental world state. + CommandSessionsWorldState, /// Track top-level tool definitions in world state and emit incremental context updates. IncrementalTools, /// Suppress the model ID announcement in incremental context after a model switch. @@ -1785,6 +1787,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::CommandSessionsWorldState, + key: "command_sessions_world_state", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::IncrementalTools, key: "incremental_tools",