mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## Why Listing hooks by working directory alone omits hooks supplied by a thread's selected execution environments, including hooks that can block a turn. ## What changed - Add `threadId` to `hooks/list` to return a loaded thread's current hook inventory, including selected, ready environments. Reject requests that also specify `cwds`. - Expose `environmentId` in hook metadata and have the TUI hooks browser query the current thread and label environment-owned hooks. Keep working-directory lookup for startup review. - Represent environment hook source paths with `PathUri` and use `LegacyAppPathString` in hook run summaries to preserve native path conventions. ## Testing Add coverage for listing a blocking environment hook and removing it after environment failure, delivering prompt input and reporting exit status in PowerShell and Bash, and running `PreToolUse` hooks only on the command's owning executor. Extend remote network tests to verify that environment hooks use the command's network policy while retaining their filesystem access. GitOrigin-RevId: 97229e22793df51f4bd502c1f9e19f0c52fb2305
188 lines
7.2 KiB
Rust
188 lines
7.2 KiB
Rust
//! Exercises the goal extension's no-activity breaker through the public API.
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_mock_responses_server_sequence;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigEdit;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::HookEventName;
|
|
use codex_app_server_protocol::HookSource;
|
|
use codex_app_server_protocol::HooksListParams;
|
|
use codex_app_server_protocol::HooksListResponse;
|
|
use codex_app_server_protocol::MergeStrategy;
|
|
use codex_app_server_protocol::ThreadGoalSetResponse;
|
|
use codex_app_server_protocol::ThreadGoalStatus;
|
|
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_features::Feature;
|
|
use core_test_support::responses;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
#[test_case::test_case(None; "empty")]
|
|
#[test_case::test_case(Some("final_answer"); "draft")]
|
|
#[test_case::test_case(Some("commentary"); "commentary")]
|
|
#[test_case::test_case(Some("tool"); "tool")]
|
|
#[test_case::test_case(Some("interrupt"); "interrupt")]
|
|
#[tokio::test]
|
|
async fn empty_goal_continuations_block_after_three_without_activity(
|
|
recovery: Option<&str>,
|
|
) -> Result<()> {
|
|
let turns = match recovery {
|
|
Some("interrupt") => 4,
|
|
Some(_) => 6,
|
|
None => 3,
|
|
};
|
|
let mut scripts = Vec::new();
|
|
for turn in 1..=turns {
|
|
let mut id = format!("response-{turn}");
|
|
let mut events = vec![responses::ev_response_created(&id)];
|
|
if recovery == Some("interrupt") {
|
|
events.push(responses::ev_assistant_message(
|
|
"progress",
|
|
"Ready for the next step",
|
|
));
|
|
events.push(responses::ev_completed_with_tokens(
|
|
&id, /*total_tokens*/ 330_000,
|
|
));
|
|
scripts.push(responses::sse(events));
|
|
break;
|
|
}
|
|
if turn == 3
|
|
&& let Some(recovery) = recovery
|
|
{
|
|
if recovery == "tool" {
|
|
events.push(responses::ev_function_call("get-goal", "get_goal", "{}"));
|
|
events.push(responses::ev_completed(&id));
|
|
scripts.push(responses::sse(events));
|
|
id.push_str("-after-tool");
|
|
events = vec![responses::ev_response_created(&id)];
|
|
} else {
|
|
let mut added = responses::ev_assistant_message("progress", "");
|
|
added["type"] = json!("response.output_item.added");
|
|
events.push(added);
|
|
events.push(responses::ev_output_text_delta("Useful progress"));
|
|
let mut event = responses::ev_assistant_message("progress", "Useful progress");
|
|
event["item"]["phase"] = json!(recovery);
|
|
events.push(event);
|
|
}
|
|
}
|
|
let mut empty_final = responses::ev_assistant_message(&format!("empty-final-{turn}"), "");
|
|
empty_final["item"]["phase"] = json!("final_answer");
|
|
events.push(empty_final);
|
|
events.push(responses::ev_completed(&id));
|
|
scripts.push(responses::sse(events));
|
|
}
|
|
let server = create_mock_responses_server_sequence(scripts).await;
|
|
let codex_home = TempDir::new()?;
|
|
let mut config = MockResponsesConfig::new(&server.uri())
|
|
.with_model("gpt-5.4")
|
|
.enable_feature(Feature::Goals);
|
|
if recovery == Some("interrupt") {
|
|
config = config
|
|
.enable_feature(Feature::CodexHooks)
|
|
.with_root_config(
|
|
"model_auto_compact_token_limit = 200000\nmodel_post_turn_compact_threshold_percent = 0",
|
|
)
|
|
.with_extra_config(
|
|
r#"
|
|
[[hooks.PreCompact]]
|
|
matcher = "auto"
|
|
[[hooks.PreCompact.hooks]]
|
|
type = "command"
|
|
command = """printf '%s' '{"continue":false}'"""
|
|
command_windows = """Write-Output '{"continue":false}'"""
|
|
"#,
|
|
);
|
|
}
|
|
config.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
if recovery == Some("interrupt") {
|
|
let list = mcp
|
|
.send_hooks_list_request(HooksListParams {
|
|
cwds: vec![codex_home.path().to_path_buf()],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let HooksListResponse { data } = mcp.read_response(list).await?;
|
|
let hook = data[0]
|
|
.hooks
|
|
.iter()
|
|
.find(|hook| {
|
|
hook.source == HookSource::User && hook.event_name == HookEventName::PreCompact
|
|
})
|
|
.expect("user config should register the pre-compact hook");
|
|
let trust = mcp
|
|
.send_config_batch_write_request(ConfigBatchWriteParams {
|
|
edits: vec![ConfigEdit {
|
|
key_path: "hooks.state".to_string(),
|
|
value: json!({hook.key.clone(): {"trusted_hash": hook.current_hash}}),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
}],
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
})
|
|
.await?;
|
|
let _: ConfigWriteResponse = mcp.read_response(trust).await?;
|
|
}
|
|
let request = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp.read_response(request).await?;
|
|
let request = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({"threadId": thread.id, "objective": "Finish the work"})),
|
|
)
|
|
.await?;
|
|
let _: ThreadGoalSetResponse = mcp.read_response(request).await?;
|
|
|
|
for turn in 1..=turns {
|
|
if turn == 3 && matches!(recovery, Some("final_answer" | "commentary")) {
|
|
let delta: serde_json::Value = timeout(
|
|
Duration::from_secs(30),
|
|
mcp.read_notification("item/agentMessage/delta"),
|
|
)
|
|
.await??;
|
|
assert_eq!(json!("Useful progress"), delta["delta"]);
|
|
}
|
|
let completed: TurnCompletedNotification = timeout(
|
|
Duration::from_secs(30),
|
|
mcp.read_notification("turn/completed"),
|
|
)
|
|
.await??;
|
|
let expected = if recovery == Some("interrupt") && turn > 1 {
|
|
TurnStatus::Interrupted
|
|
} else {
|
|
TurnStatus::Completed
|
|
};
|
|
assert_eq!(expected, completed.turn.status);
|
|
assert_eq!(None, completed.turn.error);
|
|
}
|
|
timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
let notification: ThreadGoalUpdatedNotification =
|
|
mcp.read_notification("thread/goal/updated").await?;
|
|
if notification.goal.status == ThreadGoalStatus::Blocked {
|
|
return Ok::<_, anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|