mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
hooks: define UserInstructions execution contract
This commit is contained in:
Generated
+1
@@ -3201,6 +3201,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-path-uri",
|
||||
"futures",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
|
||||
@@ -1232,6 +1232,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserInstructions => "UserInstructions",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
|
||||
@@ -2005,6 +2005,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
+1
@@ -10798,6 +10798,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
+1
@@ -7149,6 +7149,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userInstructions",
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop";
|
||||
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userInstructions" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop";
|
||||
|
||||
@@ -17,7 +17,7 @@ use ts_rs::TS;
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop
|
||||
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserInstructions, UserPromptSubmit, SubagentStart, SubagentStop, Stop
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -703,6 +703,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserInstructions => "UserInstructions",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
|
||||
@@ -18,6 +18,7 @@ const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
|
||||
const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
|
||||
const COMMAND_SKILL_PREFIX: &str = "source-command";
|
||||
const MAX_SKILL_NAME_LEN: usize = 64;
|
||||
const CODEX_ONLY_HOOK_EVENT_NAME: &str = "UserInstructions";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedDocument {
|
||||
@@ -542,7 +543,10 @@ fn append_convertible_hook_groups(
|
||||
return;
|
||||
};
|
||||
|
||||
for event_name in HOOK_EVENT_NAMES {
|
||||
for &event_name in HOOK_EVENT_NAMES {
|
||||
if event_name == CODEX_ONLY_HOOK_EVENT_NAME {
|
||||
continue;
|
||||
}
|
||||
let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1906,6 +1910,12 @@ Review carefully."""
|
||||
"SubagentStart": [{
|
||||
"matcher": "worker",
|
||||
"hooks": [{"type": "prompt", "prompt": "check"}]
|
||||
}],
|
||||
"UserInstructions": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": source_hook_command("instructions.py")
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ codex-config = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
futures = { workspace = true, features = ["alloc"] }
|
||||
regex = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"const": "UserInstructions",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission_mode": {
|
||||
"enum": [
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"plan",
|
||||
"dontAsk",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"hook_event_name",
|
||||
"model",
|
||||
"permission_mode",
|
||||
"session_id",
|
||||
"transcript_path"
|
||||
],
|
||||
"title": "user-instructions.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -61,7 +61,9 @@ pub(crate) fn select_handlers_for_matcher_inputs(
|
||||
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
|
||||
}
|
||||
}
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
|
||||
HookEventName::UserInstructions
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
@@ -141,7 +143,9 @@ pub(crate) fn completed_summary(
|
||||
|
||||
fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
match event_name {
|
||||
HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread,
|
||||
HookEventName::SessionStart
|
||||
| HookEventName::UserInstructions
|
||||
| HookEventName::SubagentStart => HookScope::Thread,
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
use crate::events::stop::StopRequest;
|
||||
use crate::events::user_instructions::UserInstructionsOutcome;
|
||||
use crate::events::user_instructions::UserInstructionsRequest;
|
||||
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
||||
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
||||
use crate::output_spill::HookOutputSpiller;
|
||||
@@ -69,6 +71,7 @@ impl ConfiguredHandler {
|
||||
codex_protocol::protocol::HookEventName::PreCompact => "pre-compact",
|
||||
codex_protocol::protocol::HookEventName::PostCompact => "post-compact",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserInstructions => "user-instructions",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start",
|
||||
codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop",
|
||||
@@ -152,6 +155,13 @@ impl ClaudeHooksEngine {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_instructions(
|
||||
&self,
|
||||
request: &UserInstructionsRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::user_instructions::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_permission_request(
|
||||
&self,
|
||||
request: &PermissionRequestRequest,
|
||||
@@ -180,6 +190,13 @@ impl ClaudeHooksEngine {
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_instructions(
|
||||
&self,
|
||||
request: UserInstructionsRequest,
|
||||
) -> UserInstructionsOutcome {
|
||||
crate::events::user_instructions::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
let session_id = request.session_id;
|
||||
let mut outcome =
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) struct GeneratedHookSchemas {
|
||||
pub pre_compact_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
pub session_start_command_output: Value,
|
||||
pub user_instructions_command_input: Value,
|
||||
pub subagent_start_command_input: Value,
|
||||
pub subagent_start_command_output: Value,
|
||||
pub subagent_stop_command_input: Value,
|
||||
@@ -77,6 +78,10 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
"session-start.command.output",
|
||||
include_str!("../../schema/generated/session-start.command.output.schema.json"),
|
||||
),
|
||||
user_instructions_command_input: parse_json_schema(
|
||||
"user-instructions.command.input",
|
||||
include_str!("../../schema/generated/user-instructions.command.input.schema.json"),
|
||||
),
|
||||
subagent_start_command_input: parse_json_schema(
|
||||
"subagent-start.command.input",
|
||||
include_str!("../../schema/generated/subagent-start.command.input.schema.json"),
|
||||
@@ -138,6 +143,7 @@ mod tests {
|
||||
assert_eq!(schemas.pre_compact_command_output["type"], "object");
|
||||
assert_eq!(schemas.session_start_command_input["type"], "object");
|
||||
assert_eq!(schemas.session_start_command_output["type"], "object");
|
||||
assert_eq!(schemas.user_instructions_command_input["type"], "object");
|
||||
assert_eq!(schemas.subagent_start_command_input["type"], "object");
|
||||
assert_eq!(schemas.subagent_start_command_output["type"], "object");
|
||||
assert_eq!(schemas.subagent_stop_command_input["type"], "object");
|
||||
|
||||
@@ -115,7 +115,9 @@ pub(crate) fn matcher_pattern_for_event(
|
||||
| HookEventName::SubagentStop
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
HookEventName::UserInstructions | HookEventName::UserPromptSubmit | HookEventName::Stop => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +264,10 @@ mod tests {
|
||||
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::UserInstructions, Some("ignored")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod post_tool_use;
|
||||
pub mod pre_tool_use;
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
pub mod user_instructions;
|
||||
pub mod user_prompt_submit;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! User-instruction hook execution.
|
||||
//!
|
||||
//! Unlike ordinary context-injecting hooks, successful stdout becomes the
|
||||
//! thread's durable user-instruction snapshot. Exactly one active handler may
|
||||
//! contribute that snapshot.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
use crate::engine::command_runner::run_command;
|
||||
use crate::engine::dispatcher;
|
||||
use crate::schema::NullableString;
|
||||
use crate::schema::UserInstructionsCommandInput;
|
||||
|
||||
/// Input supplied to the active `UserInstructions` hook.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserInstructionsRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub cwd: PathUri,
|
||||
pub command_cwd: AbsolutePathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
}
|
||||
|
||||
/// User instructions returned by a hook and their runtime-owned provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UserInstructionsResult {
|
||||
pub text: String,
|
||||
pub source_path: PathUri,
|
||||
}
|
||||
|
||||
/// Result of resolving and running the active `UserInstructions` hook.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UserInstructionsOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub result: Option<UserInstructionsResult>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
_request: &UserInstructionsRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
let matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::UserInstructions,
|
||||
/*matcher_input*/ None,
|
||||
);
|
||||
match matched.as_slice() {
|
||||
[handler] => vec![dispatcher::running_summary(handler)],
|
||||
[] | [_, _, ..] => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: UserInstructionsRequest,
|
||||
) -> UserInstructionsOutcome {
|
||||
let mut matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::UserInstructions,
|
||||
/*matcher_input*/ None,
|
||||
);
|
||||
match matched.len() {
|
||||
0 => return UserInstructionsOutcome::default(),
|
||||
1 => {}
|
||||
count => {
|
||||
return UserInstructionsOutcome {
|
||||
warnings: vec![format!(
|
||||
"UserInstructions requires at most one active hook, but found {count}; skipping all UserInstructions hooks"
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
let Some(handler) = matched.pop() else {
|
||||
return UserInstructionsOutcome::default();
|
||||
};
|
||||
|
||||
let input = UserInstructionsCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
transcript_path: NullableString::from_path(request.transcript_path),
|
||||
cwd: request.cwd.inferred_native_path_string(),
|
||||
hook_event_name: "UserInstructions".to_string(),
|
||||
model: request.model,
|
||||
permission_mode: request.permission_mode,
|
||||
};
|
||||
let input_json = match serde_json::to_string(&input) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
let warning = format!("failed to serialize UserInstructions hook input: {error}");
|
||||
return UserInstructionsOutcome {
|
||||
hook_events: common::serialization_failure_hook_events(
|
||||
vec![handler],
|
||||
/*turn_id*/ None,
|
||||
warning.clone(),
|
||||
),
|
||||
result: None,
|
||||
warnings: vec![warning],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let run_result = run_command(shell, &handler, &input_json, request.command_cwd.as_path()).await;
|
||||
parse_completed(&handler, run_result)
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
) -> UserInstructionsOutcome {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut text = None;
|
||||
let mut warning = None;
|
||||
|
||||
match run_result.error.as_deref() {
|
||||
Some(error) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: error.to_string(),
|
||||
});
|
||||
warning = Some(handler_warning(handler, &format!("failed: {error}")));
|
||||
}
|
||||
None => match run_result.exit_code {
|
||||
Some(0) => {
|
||||
let trimmed = run_result.stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
let message = "returned no instructions";
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: message.to_string(),
|
||||
});
|
||||
warning = Some(handler_warning(handler, message));
|
||||
} else {
|
||||
text = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
Some(exit_code) => {
|
||||
status = HookRunStatus::Failed;
|
||||
let mut message = format!("hook exited with code {exit_code}");
|
||||
if let Some(stderr) = common::trimmed_non_empty(&run_result.stderr) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&stderr);
|
||||
}
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: message.clone(),
|
||||
});
|
||||
warning = Some(handler_warning(handler, &format!("failed: {message}")));
|
||||
}
|
||||
None => {
|
||||
status = HookRunStatus::Failed;
|
||||
let message = "hook exited without a status code";
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: message.to_string(),
|
||||
});
|
||||
warning = Some(handler_warning(handler, &format!("failed: {message}")));
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let result = text.map(|text| UserInstructionsResult {
|
||||
text,
|
||||
source_path: PathUri::from_abs_path(&handler.source_path),
|
||||
});
|
||||
UserInstructionsOutcome {
|
||||
hook_events: vec![HookCompletedEvent {
|
||||
turn_id: None,
|
||||
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
||||
}],
|
||||
result,
|
||||
warnings: warning.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handler_warning(handler: &ConfiguredHandler, message: &str) -> String {
|
||||
format!(
|
||||
"UserInstructions hook from {} {message}",
|
||||
PathUri::from_abs_path(&handler.source_path)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "user_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,246 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookScope;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::UserInstructionsRequest;
|
||||
use super::UserInstructionsResult;
|
||||
use super::parse_completed;
|
||||
use super::preview;
|
||||
use super::run;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
#[test]
|
||||
fn successful_stdout_is_trimmed_literal_text_without_output_entry() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), " {\"literal\":true}\n", ""),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.result,
|
||||
Some(UserInstructionsResult {
|
||||
text: "{\"literal\":true}".to_string(),
|
||||
source_path: handler_source_uri(),
|
||||
})
|
||||
);
|
||||
assert_eq!(parsed.warnings, Vec::<String>::new());
|
||||
assert_eq!(parsed.hook_events[0].turn_id, None);
|
||||
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Completed);
|
||||
assert_eq!(parsed.hook_events[0].run.entries, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stdout_warns_without_instructions() {
|
||||
let parsed = parse_completed(&handler(), run_result(Some(0), " \n", ""));
|
||||
|
||||
assert_eq!(parsed.result, None);
|
||||
assert_eq!(
|
||||
parsed.warnings,
|
||||
vec![format!(
|
||||
"UserInstructions hook from {} returned no instructions",
|
||||
handler_source_uri()
|
||||
)]
|
||||
);
|
||||
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Completed);
|
||||
assert_eq!(
|
||||
parsed.hook_events[0].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: "returned no instructions".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_exit_warns_and_includes_stderr() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(7), "ignored", "provider unavailable\n"),
|
||||
);
|
||||
|
||||
assert_eq!(parsed.result, None);
|
||||
assert_eq!(
|
||||
parsed.warnings,
|
||||
vec![format!(
|
||||
"UserInstructions hook from {} failed: hook exited with code 7: provider unavailable",
|
||||
handler_source_uri()
|
||||
)]
|
||||
);
|
||||
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.hook_events[0].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited with code 7: provider unavailable".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_warns_without_instructions() {
|
||||
let mut result = run_result(/*exit_code*/ None, "ignored", "");
|
||||
result.error = Some("hook timed out after 1s".to_string());
|
||||
|
||||
let parsed = parse_completed(&handler(), result);
|
||||
|
||||
assert_eq!(parsed.result, None);
|
||||
assert_eq!(
|
||||
parsed.warnings,
|
||||
vec![format!(
|
||||
"UserInstructions hook from {} failed: hook timed out after 1s",
|
||||
handler_source_uri()
|
||||
)]
|
||||
);
|
||||
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.hook_events[0].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook timed out after 1s".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exactly_one_handler_runs_and_returns_runtime_source() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
|
||||
let command = if cfg!(windows) {
|
||||
"echo literal instructions"
|
||||
} else {
|
||||
"printf ' literal instructions '"
|
||||
};
|
||||
let handler = handler_with_command(command);
|
||||
let request = request(cwd);
|
||||
|
||||
let preview_runs = preview(std::slice::from_ref(&handler), &request);
|
||||
assert_eq!(preview_runs.len(), 1);
|
||||
assert_eq!(preview_runs[0].scope, HookScope::Thread);
|
||||
|
||||
let outcome = run(
|
||||
std::slice::from_ref(&handler),
|
||||
&CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.warnings, Vec::<String>::new());
|
||||
assert_eq!(outcome.hook_events.len(), 1);
|
||||
assert_eq!(outcome.hook_events[0].run.entries, Vec::new());
|
||||
assert_eq!(
|
||||
outcome.result,
|
||||
Some(super::UserInstructionsResult {
|
||||
text: "literal instructions".to_string(),
|
||||
source_path: PathUri::from_abs_path(&handler.source_path),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_handlers_is_unconfigured() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
|
||||
let request = request(cwd);
|
||||
|
||||
assert!(preview(&[], &request).is_empty());
|
||||
assert_eq!(
|
||||
run(
|
||||
&[],
|
||||
&CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await,
|
||||
super::UserInstructionsOutcome::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_handlers_warn_without_preview_or_execution() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
|
||||
let handlers = vec![handler(), handler()];
|
||||
let request = request(cwd);
|
||||
|
||||
assert!(preview(&handlers, &request).is_empty());
|
||||
let outcome = run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.result, None);
|
||||
assert!(outcome.hook_events.is_empty());
|
||||
assert_eq!(
|
||||
outcome.warnings,
|
||||
vec![
|
||||
"UserInstructions requires at most one active hook, but found 2; skipping all UserInstructions hooks"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
handler_with_command("echo hook")
|
||||
}
|
||||
|
||||
fn handler_source_uri() -> PathUri {
|
||||
PathUri::from_abs_path(&handler().source_path)
|
||||
}
|
||||
|
||||
fn handler_with_command(command: &str) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserInstructions,
|
||||
matcher: None,
|
||||
command: command.to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(cwd: AbsolutePathBuf) -> UserInstructionsRequest {
|
||||
UserInstructionsRequest {
|
||||
session_id: ThreadId::new(),
|
||||
cwd: PathUri::from_abs_path(&cwd),
|
||||
command_cwd: cwd,
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
|
||||
CommandRunResult {
|
||||
started_at: 1,
|
||||
completed_at: 2,
|
||||
duration_ms: 1,
|
||||
exit_code,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,14 @@ pub use declarations::plugin_hook_declarations;
|
||||
pub use engine::HookListEntry;
|
||||
pub use events::common::SubagentHookContext;
|
||||
/// Hook event names as they appear in hooks JSON and config files.
|
||||
pub const HOOK_EVENT_NAMES: [&str; 10] = [
|
||||
pub const HOOK_EVENT_NAMES: &[&str] = &[
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserInstructions",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
@@ -63,6 +64,9 @@ pub use events::session_start::StartHookTarget;
|
||||
pub use events::stop::StopHookTarget;
|
||||
pub use events::stop::StopOutcome;
|
||||
pub use events::stop::StopRequest;
|
||||
pub use events::user_instructions::UserInstructionsOutcome;
|
||||
pub use events::user_instructions::UserInstructionsRequest;
|
||||
pub use events::user_instructions::UserInstructionsResult;
|
||||
pub use events::user_prompt_submit::UserPromptSubmitOutcome;
|
||||
pub use events::user_prompt_submit::UserPromptSubmitRequest;
|
||||
pub use legacy_notify::legacy_notify_json;
|
||||
@@ -89,6 +93,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PreCompact => "pre_compact",
|
||||
HookEventName::PostCompact => "post_compact",
|
||||
HookEventName::SessionStart => "session_start",
|
||||
HookEventName::UserInstructions => "user_instructions",
|
||||
HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
HookEventName::SubagentStart => "subagent_start",
|
||||
HookEventName::SubagentStop => "subagent_stop",
|
||||
|
||||
@@ -19,6 +19,8 @@ use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
use crate::events::stop::StopRequest;
|
||||
use crate::events::user_instructions::UserInstructionsOutcome;
|
||||
use crate::events::user_instructions::UserInstructionsRequest;
|
||||
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
|
||||
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
|
||||
use crate::types::Hook;
|
||||
@@ -120,6 +122,13 @@ impl Hooks {
|
||||
self.engine.preview_pre_tool_use(request)
|
||||
}
|
||||
|
||||
pub fn preview_user_instructions(
|
||||
&self,
|
||||
request: &UserInstructionsRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_user_instructions(request)
|
||||
}
|
||||
|
||||
pub fn preview_permission_request(
|
||||
&self,
|
||||
request: &PermissionRequestRequest,
|
||||
@@ -142,6 +151,13 @@ impl Hooks {
|
||||
self.engine.run_session_start(request, turn_id).await
|
||||
}
|
||||
|
||||
pub async fn run_user_instructions(
|
||||
&self,
|
||||
request: UserInstructionsRequest,
|
||||
) -> UserInstructionsOutcome {
|
||||
self.engine.run_user_instructions(request).await
|
||||
}
|
||||
|
||||
pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
self.engine.run_pre_tool_use(request).await
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const PRE_COMPACT_INPUT_FIXTURE: &str = "pre-compact.command.input.schema.json";
|
||||
const PRE_COMPACT_OUTPUT_FIXTURE: &str = "pre-compact.command.output.schema.json";
|
||||
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
|
||||
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
|
||||
const USER_INSTRUCTIONS_INPUT_FIXTURE: &str = "user-instructions.command.input.schema.json";
|
||||
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
|
||||
const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
|
||||
const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json";
|
||||
@@ -109,6 +110,8 @@ pub(crate) enum HookEventNameWire {
|
||||
PostCompact,
|
||||
#[serde(rename = "SessionStart")]
|
||||
SessionStart,
|
||||
#[serde(rename = "UserInstructions")]
|
||||
UserInstructions,
|
||||
#[serde(rename = "UserPromptSubmit")]
|
||||
UserPromptSubmit,
|
||||
#[serde(rename = "SubagentStart")]
|
||||
@@ -516,6 +519,20 @@ impl SessionStartCommandInput {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "user-instructions.command.input")]
|
||||
pub(crate) struct UserInstructionsCommandInput {
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) transcript_path: NullableString,
|
||||
pub(crate) cwd: String,
|
||||
#[schemars(schema_with = "user_instructions_hook_event_name_schema")]
|
||||
pub(crate) hook_event_name: String,
|
||||
pub(crate) model: String,
|
||||
#[schemars(schema_with = "permission_mode_schema")]
|
||||
pub(crate) permission_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "subagent-start.command.input")]
|
||||
@@ -646,6 +663,10 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
|
||||
schema_json::<SessionStartCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(USER_INSTRUCTIONS_INPUT_FIXTURE),
|
||||
schema_json::<UserInstructionsCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
|
||||
schema_json::<UserPromptSubmitCommandInput>()?,
|
||||
@@ -737,6 +758,10 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("SessionStart")
|
||||
}
|
||||
|
||||
fn user_instructions_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("UserInstructions")
|
||||
}
|
||||
|
||||
fn post_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PostToolUse")
|
||||
}
|
||||
@@ -853,6 +878,7 @@ mod tests {
|
||||
use super::SubagentStartCommandInput;
|
||||
use super::SubagentStartCommandOutputWire;
|
||||
use super::SubagentStopCommandInput;
|
||||
use super::USER_INSTRUCTIONS_INPUT_FIXTURE;
|
||||
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
|
||||
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
|
||||
use super::UserPromptSubmitCommandInput;
|
||||
@@ -904,6 +930,9 @@ mod tests {
|
||||
SESSION_START_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/session-start.command.output.schema.json")
|
||||
}
|
||||
USER_INSTRUCTIONS_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/user-instructions.command.input.schema.json")
|
||||
}
|
||||
USER_PROMPT_SUBMIT_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/user-prompt-submit.command.input.schema.json")
|
||||
}
|
||||
@@ -969,6 +998,7 @@ mod tests {
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE,
|
||||
SESSION_START_INPUT_FIXTURE,
|
||||
SESSION_START_OUTPUT_FIXTURE,
|
||||
USER_INSTRUCTIONS_INPUT_FIXTURE,
|
||||
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
|
||||
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
|
||||
SUBAGENT_START_INPUT_FIXTURE,
|
||||
|
||||
@@ -1479,6 +1479,7 @@ pub enum HookEventName {
|
||||
PreCompact,
|
||||
PostCompact,
|
||||
SessionStart,
|
||||
UserInstructions,
|
||||
UserPromptSubmit,
|
||||
SubagentStart,
|
||||
SubagentStop,
|
||||
|
||||
@@ -735,6 +735,7 @@ fn event_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserInstructions => "UserInstructions",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
@@ -750,6 +751,7 @@ fn event_description(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PreCompact => "Before context compaction",
|
||||
HookEventName::PostCompact => "After context compaction",
|
||||
HookEventName::SessionStart => "When a new session starts",
|
||||
HookEventName::UserInstructions => "When user instructions are loaded",
|
||||
HookEventName::UserPromptSubmit => "When the user submits a prompt",
|
||||
HookEventName::SubagentStart => "When a subagent is created",
|
||||
HookEventName::SubagentStop => "Right before a subagent ends its turn",
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ expression: "render_lines(&view, 112)"
|
||||
PreCompact 0 0 Before context compaction
|
||||
PostCompact 0 0 After context compaction
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserInstructions 0 0 When user instructions are loaded
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
SubagentStart 0 0 When a subagent is created
|
||||
SubagentStop 0 0 Right before a subagent ends its turn
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ expression: "render_lines(&view, 112)"
|
||||
PreCompact 0 0 Before context compaction
|
||||
PostCompact 0 0 After context compaction
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserInstructions 0 0 When user instructions are loaded
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
SubagentStart 0 0 When a subagent is created
|
||||
SubagentStop 0 0 Right before a subagent ends its turn
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ expression: "render_lines(&view, 112)"
|
||||
PreCompact 0 0 0 Before context compaction
|
||||
PostCompact 0 0 0 After context compaction
|
||||
SessionStart 0 0 0 When a new session starts
|
||||
UserInstructions 0 0 0 When user instructions are loaded
|
||||
UserPromptSubmit 0 0 0 When the user submits a prompt
|
||||
SubagentStart 0 0 0 When a subagent is created
|
||||
SubagentStop 0 0 0 Right before a subagent ends its turn
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ expression: popup
|
||||
PreCompact 0 0 Before context compaction
|
||||
PostCompact 0 0 After context compaction
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserInstructions 0 0 When user instructions are loaded
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
SubagentStart 0 0 When a subagent is created
|
||||
SubagentStop 0 0 Right before a subagent ends its turn
|
||||
|
||||
@@ -1682,6 +1682,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s
|
||||
codex_app_server_protocol::HookEventName::PreCompact => "PreCompact",
|
||||
codex_app_server_protocol::HookEventName::PostCompact => "PostCompact",
|
||||
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
|
||||
codex_app_server_protocol::HookEventName::UserInstructions => "UserInstructions",
|
||||
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart",
|
||||
codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop",
|
||||
|
||||
@@ -819,6 +819,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserInstructions => "UserInstructions",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::SubagentStart => "SubagentStart",
|
||||
HookEventName::SubagentStop => "SubagentStop",
|
||||
|
||||
Reference in New Issue
Block a user