Surface environment startup failure reasons to the model (#44277)

## Why

Failed environments were omitted from environment context, and `wait_for_environment` returned a generic failure or treated an already-failed environment as unselected. The model needs the failure reason to explain problems such as an empty repository.

## What changed

- Preserve the selected environment and error when startup or configuration fails.
- Include failed status and XML-escaped error details in environment context, and return the reason from `wait_for_environment` for failures detected before or during a wait.
- Limit error details to 256 bytes per environment and 512 bytes total in context, preserving UTF-8 boundaries. Limit wait failure details to 256 bytes.

## Testing

Add coverage for failures before a turn and while waiting, verifying that the model receives the reason and the turn completes. Add unit tests for XML escaping, incremental context updates, clearing errors on recovery, and UTF-8-safe truncation.

GitOrigin-RevId: 093ab9b447c7b3243fbb53b665180db95479f2a7
This commit is contained in:
Rasmus Rygaard authored and copyberry committed 2026-09-09 18:50:32 +00:00
1 parent f5c5d9b2b0
commit 9caddc5cf5
7 files changed
+287 -16

No files matched your search

+3
View File
@@ -4023,3 +4023,6 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> {
.await?;
Ok(())
}
#[path = "remote_env_failure_tests.rs"]
mod failure_tests;
@@ -0,0 +1,106 @@
//! Provisioning failures reach the model without terminating its turn.
use super::*;
use pretty_assertions::assert_eq;
use test_case::test_case;
#[test_case(true; "failed_before_turn")]
#[test_case(false; "failed_while_waiting")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn provisioning_failure_reaches_model_and_turn_continues(
fail_before_turn: bool,
) -> Result<()> {
const REASON: &str = "This repository is empty. Push an initial commit, then retry.";
const CALL_ID: &str = "wait-for-failed-environment";
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_function_call(
CALL_ID,
"wait_for_environment",
&json!({
"environment_id": REMOTE_ENVIRONMENT_ID,
})
.to_string(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message(
"message-1",
"I can explain how to initialize the repository.",
),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex_with_wait_for_environment().with_config(|config| {
assert!(config.features.enable(Feature::DeferredExecutor).is_ok());
});
let test = expect_startup(builder.build(&server)).await;
let manager = test.thread_manager.environment_manager();
let provider = Arc::new(FailingNoiseConnectProvider::default());
manager.materialize_pending_noise_environment(
REMOTE_ENVIRONMENT_ID.to_string(),
provider.clone(),
)?;
if fail_before_turn {
manager.report_environment_provisioning_status(
REMOTE_ENVIRONMENT_ID.to_string(),
Err(REASON.to_string()),
provider.clone(),
)?;
}
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "inspect the repository".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
config: EnvironmentConfigState::FromThread,
}],
)),
..Default::default()
}),
)
.await?;
wait_for_response_request_count(&response_mock, /*expected_count*/ 1).await;
if !fail_before_turn {
manager.report_environment_provisioning_status(
REMOTE_ENVIRONMENT_ID.to_string(),
Err(REASON.to_string()),
provider,
)?;
}
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let (output, _) = requests[1]
.function_call_output_content_and_success(CALL_ID)
.context("the model should receive the failed wait result")?;
assert!(
output.as_deref().is_some_and(|text| text.contains(REASON)),
"{output:?}"
);
let context_request = if fail_before_turn {
&requests[0]
} else {
&requests[1]
};
let context = context_request.message_input_texts("user").join("\n");
assert!(context.contains("<status>failed</status>"), "{context}");
assert!(context.contains(REASON), "{context}");
Ok(())
}