Files
dev/code-rs/core/tests/tool_hooks.rs
T

213 lines
6.7 KiB
Rust

#![allow(clippy::unwrap_used)]
mod common;
use common::load_default_config_for_test;
use code_core::built_in_model_providers;
use code_core::config_types::{ProjectHookConfig, ProjectHookEvent};
use code_core::project_features::ProjectHooks;
use code_core::protocol::{AskForApproval, EventMsg, InputItem, Op, SandboxPolicy};
use code_core::{CodexAuth, ConversationManager, ModelProviderInfo};
use serde_json::json;
use std::fs::{self, File};
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sse_response(body: String) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_hooks_fire_for_shell_exec() {
let code_home = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
let log_path = project_dir.path().join("hooks.log");
File::create(&log_path).unwrap();
let mut config = load_default_config_for_test(&code_home);
config.cwd = project_dir.path().to_path_buf();
config.approval_policy = AskForApproval::Never;
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
let hook_cmd = |label: &str| {
vec![
"bash".to_string(),
"-lc".to_string(),
format!("echo {label}:${{CODE_HOOK_EVENT}} >> {}", log_path.display()),
]
};
let hook_configs = vec![
ProjectHookConfig {
event: ProjectHookEvent::ToolBefore,
name: Some("before".to_string()),
command: hook_cmd("before"),
cwd: None,
env: None,
timeout_ms: None,
run_in_background: Some(false),
},
ProjectHookConfig {
event: ProjectHookEvent::ToolAfter,
name: Some("after".to_string()),
command: hook_cmd("after"),
cwd: None,
env: None,
timeout_ms: None,
run_in_background: Some(false),
},
];
config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd);
let server = MockServer::start().await;
let function_call_args = json!({
"command": ["bash", "-lc", "echo exec-body"],
"workdir": config.cwd,
"timeout_ms": null,
"sandbox_permissions": null,
"justification": null,
});
let function_call_item = json!({
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": "call-1",
"call_id": "call-1",
"name": "shell",
"arguments": function_call_args.to_string(),
}
});
let completed_one = json!({
"type": "response.completed",
"response": {
"id": "resp-1",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
}
}
});
let body_one = format!(
"event: response.output_item.done\ndata: {}\n\n\
event: response.completed\ndata: {}\n\n",
function_call_item,
completed_one
);
let message_item = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"id": "msg-1",
"role": "assistant",
"content": [{"type": "output_text", "text": "done"}],
}
});
let completed_two = json!({
"type": "response.completed",
"response": {
"id": "resp-2",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
}
}
});
let body_two = format!(
"event: response.output_item.done\ndata: {}\n\n\
event: response.completed\ndata: {}\n\n",
message_item,
completed_two
);
Mock::given(method("POST"))
.and(path_regex(".*/responses$"))
.respond_with(sse_response(body_one))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path_regex(".*/responses$"))
.respond_with(sse_response(body_two))
.up_to_n_times(1)
.mount(&server)
.await;
config.model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
config.model = "gpt-5.1-codex".to_string();
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation(config)
.await
.expect("create conversation")
.conversation;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "run hook".into(),
}],
final_output_json_schema: None,
})
.await
.unwrap();
let mut events = Vec::new();
let mut saw_task_complete = false;
for _ in 0..20 {
match timeout(std::time::Duration::from_secs(5), codex.next_event()).await {
Ok(Ok(event)) => {
if matches!(event.msg, EventMsg::TaskComplete(_)) {
saw_task_complete = true;
}
events.push(event.msg.clone());
if saw_task_complete {
break;
}
}
Ok(Err(err)) => panic!("unexpected error receiving event: {err:?}"),
Err(_) => break,
}
}
assert!(saw_task_complete, "did not receive TaskComplete event");
let hook_before_seen = events.iter().any(|msg| match msg {
EventMsg::ExecCommandBegin(ev) => ev.call_id.contains("_hook_tool_before"),
_ => false,
});
let hook_after_seen = events.iter().any(|msg| match msg {
EventMsg::ExecCommandEnd(ev) => ev.call_id.contains("_hook_tool_after"),
_ => false,
});
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "expected two model requests (tool + follow-up)");
assert!(hook_before_seen, "tool.before hook did not emit ExecCommandBegin");
assert!(hook_after_seen, "tool.after hook did not emit ExecCommandEnd");
let log_contents = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = log_contents.lines().collect();
assert!(lines.iter().any(|l| l.contains("before:tool.before")));
assert!(lines.iter().any(|l| l.contains("after:tool.after")));
assert!(lines.first().unwrap().contains("before"));
}