mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Why Hook configurations can mark command handlers as asynchronous, but Codex previously skipped those handlers outside `SessionEnd`. ## What changed - Run asynchronous command hooks in the background with a per-session concurrency limit, while keeping `SessionEnd` hooks synchronous. - Prevent asynchronous hooks from blocking, stopping, rewriting, or otherwise controlling the operation that launched them. - Deliver warnings and additional context at safe turn boundaries: inject results into an active turn after sampling, or buffer them ahead of the next user prompt when the session is idle. - Preserve in-flight hooks across configuration reloads, scope spilled output to the thread, and abort outstanding work during session shutdown. ## Testing Add unit and integration coverage for background scheduling, concurrency, output parsing, active and idle result delivery, configuration reloads, and shutdown cleanup. GitOrigin-RevId: 8094552e4afe7b47b09a61bb575bb20f4e491d8d
84 lines
2.8 KiB
Rust
84 lines
2.8 KiB
Rust
use super::*;
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn hook_output_spiller_is_scoped_to_its_thread() {
|
|
let thread_id = ThreadId::new();
|
|
let spiller = HookOutputSpiller::new(thread_id);
|
|
|
|
assert_eq!(
|
|
spiller.output_dir,
|
|
AbsolutePathBuf::resolve_path_against_base(std::env::temp_dir(), "/")
|
|
.join(HOOK_OUTPUTS_DIR)
|
|
.join(thread_id.to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn small_hook_output_remains_inline() -> Result<()> {
|
|
let dir = tempdir()?;
|
|
let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR);
|
|
let spiller = HookOutputSpiller {
|
|
output_dir: output_dir.clone(),
|
|
};
|
|
|
|
let output = spiller.maybe_spill_text("short".to_string()).await;
|
|
|
|
assert_eq!(output, "short");
|
|
assert!(!output_dir.exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn large_hook_output_spills_to_file() -> Result<()> {
|
|
let dir = tempdir()?;
|
|
let text = "hook output ".repeat(1_000);
|
|
let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR);
|
|
let spiller = HookOutputSpiller { output_dir };
|
|
|
|
let output = spiller.maybe_spill_text(text.clone()).await;
|
|
|
|
assert!(output.contains("tokens truncated"));
|
|
let path = output
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("Full hook output saved to: "))
|
|
.context("spill path")?;
|
|
assert_eq!(fs::read_to_string(path).await?, text);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn additional_contexts_apply_limits_individually() -> Result<()> {
|
|
let dir = tempdir()?;
|
|
let limited_text = "limited hook output ".repeat(1_000);
|
|
let unlimited_text = "unlimited hook output ".repeat(5_000);
|
|
assert!(approx_token_count(&unlimited_text) > 10_000);
|
|
let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR);
|
|
let spiller = HookOutputSpiller { output_dir };
|
|
let output = spiller
|
|
.maybe_spill_additional_contexts(vec![
|
|
AdditionalContext {
|
|
text: limited_text.clone(),
|
|
limit: AdditionalContextLimit::from_config(Some(1)),
|
|
},
|
|
AdditionalContext {
|
|
text: unlimited_text.clone(),
|
|
limit: AdditionalContextLimit::from_config(Some(0)),
|
|
},
|
|
AdditionalContext {
|
|
text: unlimited_text.clone(),
|
|
limit: AdditionalContextLimit::from_config(Some(usize::MAX)),
|
|
},
|
|
])
|
|
.await;
|
|
let [limited_output, zero_limit_output, high_limit_output] = output.as_slice() else {
|
|
panic!("expected one output for each additional context");
|
|
};
|
|
assert!(limited_output.contains("Full hook output saved to:"));
|
|
assert_eq!(zero_limit_output, &unlimited_text);
|
|
assert_eq!(high_limit_output, &unlimited_text);
|
|
Ok(())
|
|
}
|