mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Add presentation hint for quiet managed hooks
This commit is contained in:
@@ -1873,6 +1873,14 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"visibilityHint": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HookVisibilityHint"
|
||||
}
|
||||
],
|
||||
"default": "default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1932,6 +1940,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HookVisibilityHint": {
|
||||
"enum": [
|
||||
"default",
|
||||
"hidden"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ItemCompletedNotification": {
|
||||
"properties": {
|
||||
"completedAtMs": {
|
||||
|
||||
+15
@@ -9787,6 +9787,14 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"visibilityHint": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/HookVisibilityHint"
|
||||
}
|
||||
],
|
||||
"default": "default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9857,6 +9865,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HookVisibilityHint": {
|
||||
"enum": [
|
||||
"default",
|
||||
"hidden"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HooksListEntry": {
|
||||
"properties": {
|
||||
"cwd": {
|
||||
|
||||
+15
@@ -6287,6 +6287,14 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"visibilityHint": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HookVisibilityHint"
|
||||
}
|
||||
],
|
||||
"default": "default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -6357,6 +6365,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HookVisibilityHint": {
|
||||
"enum": [
|
||||
"default",
|
||||
"hidden"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HooksListEntry": {
|
||||
"properties": {
|
||||
"cwd": {
|
||||
|
||||
@@ -132,6 +132,14 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"visibilityHint": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HookVisibilityHint"
|
||||
}
|
||||
],
|
||||
"default": "default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -169,6 +177,13 @@
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HookVisibilityHint": {
|
||||
"enum": [
|
||||
"default",
|
||||
"hidden"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
@@ -132,6 +132,14 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"visibilityHint": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HookVisibilityHint"
|
||||
}
|
||||
],
|
||||
"default": "default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -169,6 +177,13 @@
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HookVisibilityHint": {
|
||||
"enum": [
|
||||
"default",
|
||||
"hidden"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
@@ -9,5 +9,6 @@ import type { HookOutputEntry } from "./HookOutputEntry";
|
||||
import type { HookRunStatus } from "./HookRunStatus";
|
||||
import type { HookScope } from "./HookScope";
|
||||
import type { HookSource } from "./HookSource";
|
||||
import type { HookVisibilityHint } from "./HookVisibilityHint";
|
||||
|
||||
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
|
||||
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, visibilityHint: HookVisibilityHint, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookVisibilityHint = "default" | "hidden";
|
||||
@@ -161,6 +161,7 @@ export type { HookScope } from "./HookScope";
|
||||
export type { HookSource } from "./HookSource";
|
||||
export type { HookStartedNotification } from "./HookStartedNotification";
|
||||
export type { HookTrustStatus } from "./HookTrustStatus";
|
||||
export type { HookVisibilityHint } from "./HookVisibilityHint";
|
||||
export type { HooksListEntry } from "./HooksListEntry";
|
||||
export type { HooksListParams } from "./HooksListParams";
|
||||
export type { HooksListResponse } from "./HooksListResponse";
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
|
||||
use codex_protocol::protocol::HookScope as CoreHookScope;
|
||||
use codex_protocol::protocol::HookSource as CoreHookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus;
|
||||
use codex_protocol::protocol::HookVisibilityHint as CoreHookVisibilityHint;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
@@ -70,6 +71,14 @@ v2_enum_from_core!(
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
#[derive(Default)]
|
||||
pub enum HookVisibilityHint from CoreHookVisibilityHint {
|
||||
#[default]
|
||||
Default, Hidden
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookOutputEntryKind from CoreHookOutputEntryKind {
|
||||
Warning, Stop, Feedback, Context, Error
|
||||
@@ -108,6 +117,8 @@ pub struct HookRunSummary {
|
||||
pub display_order: i64,
|
||||
pub status: HookRunStatus,
|
||||
pub status_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visibility_hint: HookVisibilityHint,
|
||||
pub started_at: i64,
|
||||
pub completed_at: Option<i64>,
|
||||
pub duration_ms: Option<i64>,
|
||||
@@ -127,6 +138,7 @@ impl From<CoreHookRunSummary> for HookRunSummary {
|
||||
display_order: value.display_order,
|
||||
status: value.status.into(),
|
||||
status_message: value.status_message,
|
||||
visibility_hint: value.visibility_hint.into(),
|
||||
started_at: value.started_at,
|
||||
completed_at: value.completed_at,
|
||||
duration_ms: value.duration_ms,
|
||||
|
||||
@@ -1550,6 +1550,8 @@ For linked Git worktrees, project hook declarations come from the matching `.cod
|
||||
|
||||
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
|
||||
|
||||
Hook execution notifications still report every started and completed run. `HookRunSummary.visibilityHint` is a presentation-only hint (`default` or `hidden`) that clients may use to quiet routine managed-hook rendering without suppressing consequential outcomes.
|
||||
|
||||
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
|
||||
|
||||
```json
|
||||
|
||||
@@ -500,6 +500,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
|
||||
timeout_sec,
|
||||
r#async,
|
||||
status_message,
|
||||
..
|
||||
} => ConfiguredHookHandler::Command {
|
||||
command,
|
||||
command_windows,
|
||||
|
||||
@@ -57,6 +57,7 @@ fn command_hook_hash(
|
||||
timeout_sec: Some(timeout_sec),
|
||||
r#async: false,
|
||||
status_message: status_message.map(ToOwned::to_owned),
|
||||
visibility_hint: Default::default(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -134,6 +135,13 @@ pub enum HookHandlerConfig {
|
||||
r#async: bool,
|
||||
#[serde(default, rename = "statusMessage")]
|
||||
status_message: Option<String>,
|
||||
#[schemars(skip)]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "visibilityHint",
|
||||
skip_serializing_if = "HookVisibilityHint::is_default"
|
||||
)]
|
||||
visibility_hint: HookVisibilityHint,
|
||||
},
|
||||
#[serde(rename = "prompt")]
|
||||
Prompt {},
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::HooksFile;
|
||||
use super::HooksToml;
|
||||
use super::ManagedHooksRequirementsToml;
|
||||
use super::MatcherGroup;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
|
||||
#[test]
|
||||
fn hooks_file_deserializes_existing_json_shape() {
|
||||
@@ -44,6 +45,7 @@ fn hooks_file_deserializes_existing_json_shape() {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -79,6 +81,7 @@ statusMessage = "checking"
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -116,6 +119,7 @@ command = "python3 /tmp/pre.py"
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -143,6 +147,7 @@ matcher = "^Bash$"
|
||||
[[PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "python3 /enterprise/place/pre.py"
|
||||
visibilityHint = "hidden"
|
||||
"#,
|
||||
)
|
||||
.expect("requirements hooks TOML should deserialize");
|
||||
@@ -161,6 +166,7 @@ command = "python3 /enterprise/place/pre.py"
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Hidden,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -197,6 +203,7 @@ command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -232,6 +239,7 @@ commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
|
||||
@@ -1267,6 +1267,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
|
||||
@@ -598,18 +598,24 @@ fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookScope;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookStartedEvent;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::additional_context_messages;
|
||||
use super::emit_hook_completed_events;
|
||||
use super::emit_hook_started_events;
|
||||
use super::hook_run_analytics_payload;
|
||||
use super::hook_run_metric_tags;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session::tests::make_session_and_context_with_rx;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
@@ -684,6 +690,56 @@ mod tests {
|
||||
assert_eq!(hook.status, HookRunStatus::Failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_hook_lifecycle_events_still_reach_clients() {
|
||||
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
let hidden_run = HookRunSummary {
|
||||
visibility_hint: HookVisibilityHint::Hidden,
|
||||
..sample_hook_run(HookRunStatus::Running, HookSource::CloudRequirements)
|
||||
};
|
||||
let completed_run = HookRunSummary {
|
||||
status: HookRunStatus::Completed,
|
||||
completed_at: Some(37),
|
||||
duration_ms: Some(27),
|
||||
..hidden_run.clone()
|
||||
};
|
||||
|
||||
emit_hook_started_events(&session, &turn_context, vec![hidden_run.clone()]).await;
|
||||
emit_hook_completed_events(
|
||||
&session,
|
||||
&turn_context,
|
||||
vec![HookCompletedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run: completed_run.clone(),
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
|
||||
let started = rx.recv().await.expect("hook started event");
|
||||
match started.msg {
|
||||
EventMsg::HookStarted(event) => assert_eq!(
|
||||
event,
|
||||
HookStartedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run: hidden_run,
|
||||
}
|
||||
),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
let completed = rx.recv().await.expect("hook completed event");
|
||||
match completed.msg {
|
||||
EventMsg::HookCompleted(event) => assert_eq!(
|
||||
event,
|
||||
HookCompletedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run: completed_run,
|
||||
}
|
||||
),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_run_metric_tags_match_analytics_shape() {
|
||||
let run = sample_hook_run(HookRunStatus::Blocked, HookSource::Project);
|
||||
@@ -736,6 +792,7 @@ mod tests {
|
||||
display_order: 0,
|
||||
status,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 10,
|
||||
completed_at: Some(37),
|
||||
duration_ms: Some(27),
|
||||
|
||||
@@ -1315,6 +1315,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> {
|
||||
timeout_sec: Some(600),
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::events::common::validate_matcher_pattern;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
@@ -434,7 +435,19 @@ fn append_matcher_groups(
|
||||
timeout_sec,
|
||||
r#async,
|
||||
status_message,
|
||||
visibility_hint,
|
||||
} => {
|
||||
let visibility_hint = if source.is_managed {
|
||||
visibility_hint
|
||||
} else if visibility_hint == HookVisibilityHint::Hidden {
|
||||
warnings.push(format!(
|
||||
"ignoring visibilityHint in {}: hidden hooks are only supported for managed hook sources",
|
||||
source.path.display()
|
||||
));
|
||||
HookVisibilityHint::Default
|
||||
} else {
|
||||
HookVisibilityHint::Default
|
||||
};
|
||||
let command = if cfg!(windows) {
|
||||
command_windows.unwrap_or(command)
|
||||
} else {
|
||||
@@ -461,6 +474,7 @@ fn append_matcher_groups(
|
||||
timeout_sec: Some(timeout_sec),
|
||||
r#async,
|
||||
status_message: status_message.clone(),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
};
|
||||
let current_hash =
|
||||
command_hook_hash(event_name, matcher, &group, normalized_handler);
|
||||
@@ -509,6 +523,7 @@ fn append_matcher_groups(
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
visibility_hint,
|
||||
});
|
||||
}
|
||||
*display_order += 1;
|
||||
@@ -630,6 +645,7 @@ mod tests {
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::TomlValue;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
|
||||
fn source_path() -> AbsolutePathBuf {
|
||||
test_path_buf("/tmp/hooks.json").abs()
|
||||
@@ -681,6 +697,7 @@ mod tests {
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -716,6 +733,7 @@ mod tests {
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -751,6 +769,7 @@ mod tests {
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -868,6 +887,103 @@ mod tests {
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_hint_does_not_change_command_trust_hash() {
|
||||
let source_path = source_path();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
let mut default_entries = Vec::new();
|
||||
let mut hidden_entries = Vec::new();
|
||||
let mut default_handlers = Vec::new();
|
||||
let mut hidden_handlers = Vec::new();
|
||||
let mut default_warnings = Vec::new();
|
||||
let mut hidden_warnings = Vec::new();
|
||||
let mut default_display_order = 0;
|
||||
let mut hidden_display_order = 0;
|
||||
let default_group = command_group(Some("^Bash$"));
|
||||
let mut hidden_group = command_group(Some("^Bash$"));
|
||||
let HookHandlerConfig::Command {
|
||||
visibility_hint, ..
|
||||
} = &mut hidden_group.hooks[0]
|
||||
else {
|
||||
unreachable!("command_group should create a command hook");
|
||||
};
|
||||
*visibility_hint = HookVisibilityHint::Hidden;
|
||||
|
||||
append_matcher_groups(
|
||||
&mut default_handlers,
|
||||
&mut default_entries,
|
||||
&mut default_warnings,
|
||||
&mut default_display_order,
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PreToolUse,
|
||||
vec![default_group],
|
||||
);
|
||||
append_matcher_groups(
|
||||
&mut hidden_handlers,
|
||||
&mut hidden_entries,
|
||||
&mut hidden_warnings,
|
||||
&mut hidden_display_order,
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PreToolUse,
|
||||
vec![hidden_group],
|
||||
);
|
||||
|
||||
assert_eq!(default_warnings, Vec::<String>::new());
|
||||
assert_eq!(hidden_warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
default_entries[0].current_hash,
|
||||
hidden_entries[0].current_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmanaged_visibility_hint_is_ignored_with_warning() {
|
||||
let source_path = source_path();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
let mut handlers = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let mut hidden_group = command_group(Some("^Bash$"));
|
||||
let HookHandlerConfig::Command {
|
||||
visibility_hint, ..
|
||||
} = &mut hidden_group.hooks[0]
|
||||
else {
|
||||
unreachable!("command_group should create a command hook");
|
||||
};
|
||||
*visibility_hint = HookVisibilityHint::Hidden;
|
||||
let unmanaged_source = super::HookHandlerSource {
|
||||
path: &source_path,
|
||||
key_source: source_path.display().to_string(),
|
||||
source: HookSource::User,
|
||||
is_managed: false,
|
||||
bypass_hook_trust: false,
|
||||
hook_states: &hook_states,
|
||||
env: std::collections::HashMap::new(),
|
||||
plugin_id: None,
|
||||
};
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut entries,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&unmanaged_source,
|
||||
HookEventName::PreToolUse,
|
||||
vec![hidden_group],
|
||||
);
|
||||
|
||||
assert_eq!(handlers, Vec::<ConfiguredHandler>::new());
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
warnings,
|
||||
vec![format!(
|
||||
"ignoring visibilityHint in {}: hidden hooks are only supported for managed hook sources",
|
||||
source_path.display()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_hook_discovery_ignores_malformed_state_entries() {
|
||||
let layer = ConfigLayerEntry::new(
|
||||
@@ -893,6 +1009,7 @@ mod tests {
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -923,6 +1040,7 @@ mod tests {
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
|
||||
display_order: handler.display_order,
|
||||
status: HookRunStatus::Running,
|
||||
status_message: handler.status_message.clone(),
|
||||
visibility_hint: handler.visibility_hint,
|
||||
started_at: chrono::Utc::now().timestamp(),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
@@ -130,6 +131,7 @@ pub(crate) fn completed_summary(
|
||||
display_order: handler.display_order,
|
||||
status,
|
||||
status_message: handler.status_message.clone(),
|
||||
visibility_hint: handler.visibility_hint,
|
||||
started_at: run_result.started_at,
|
||||
completed_at: Some(run_result.completed_at),
|
||||
duration_ms: Some(run_result.duration_ms),
|
||||
@@ -177,6 +179,7 @@ mod tests {
|
||||
source: HookSource::User,
|
||||
display_order,
|
||||
env: std::collections::HashMap::new(),
|
||||
visibility_hint: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -46,6 +47,7 @@ pub(crate) struct ConfiguredHandler {
|
||||
pub command: String,
|
||||
pub timeout_sec: u64,
|
||||
pub status_message: Option<String>,
|
||||
pub visibility_hint: HookVisibilityHint,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub source: HookSource,
|
||||
pub display_order: i64,
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use codex_protocol::protocol::HookVisibilityHint;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -66,6 +67,7 @@ fn pre_tool_use_hook_events(command: impl Into<String>) -> HookEventsToml {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -173,6 +175,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Hidden,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -235,6 +238,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
});
|
||||
assert_eq!(preview.len(), 1);
|
||||
assert_eq!(preview[0].source_path, managed_dir);
|
||||
assert_eq!(preview[0].visibility_hint, HookVisibilityHint::Hidden);
|
||||
|
||||
let outcome = engine
|
||||
.run_pre_tool_use(PreToolUseRequest {
|
||||
@@ -252,6 +256,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
.await;
|
||||
|
||||
assert!(!outcome.should_block);
|
||||
assert_eq!(
|
||||
outcome.hook_events[0].run.visibility_hint,
|
||||
HookVisibilityHint::Hidden
|
||||
);
|
||||
let log_contents = fs::read_to_string(log_path).expect("read managed hook log");
|
||||
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
|
||||
}
|
||||
@@ -274,6 +282,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -352,6 +361,7 @@ fn unknown_requirement_source_hooks_stay_managed() {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -420,6 +430,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -651,6 +662,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -1146,6 +1158,7 @@ print(json.dumps({
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -1258,6 +1271,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
|
||||
timeout_sec: Some(5),
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
|
||||
@@ -589,6 +589,7 @@ mod tests {
|
||||
command: "python3 compact_hook.py".to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: Some("running compact hook".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -548,6 +548,7 @@ mod tests {
|
||||
command: "python3 post_tool_use_hook.py".to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: Some("running post tool use hook".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -740,6 +740,7 @@ mod tests {
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -361,6 +361,7 @@ mod tests {
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -528,6 +528,7 @@ mod tests {
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -419,6 +419,7 @@ mod tests {
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
visibility_hint: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
|
||||
@@ -1518,6 +1518,20 @@ pub enum HookRunStatus {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookVisibilityHint {
|
||||
#[default]
|
||||
Default,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl HookVisibilityHint {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookOutputEntryKind {
|
||||
@@ -1549,6 +1563,8 @@ pub struct HookRunSummary {
|
||||
pub display_order: i64,
|
||||
pub status: HookRunStatus,
|
||||
pub status_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visibility_hint: HookVisibilityHint,
|
||||
#[ts(type = "number")]
|
||||
pub started_at: i64,
|
||||
#[ts(type = "number | null")]
|
||||
|
||||
@@ -413,6 +413,7 @@ mod tests {
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Running,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
@@ -436,6 +437,7 @@ mod tests {
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Stopped,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
|
||||
@@ -2917,6 +2917,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
|
||||
if history_cell::hook_run_should_skip_render(&run) {
|
||||
return;
|
||||
}
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.flush_completed_hook_output();
|
||||
match self.active_hook_cell.as_mut() {
|
||||
@@ -2936,6 +2939,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) {
|
||||
if history_cell::hook_run_should_skip_render(&completed) {
|
||||
return;
|
||||
}
|
||||
let completed_existing_run = self
|
||||
.active_hook_cell
|
||||
.as_mut()
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: "format!(\"{routine_running_snapshot}\\n\\n{routine_completed_snapshot}\\n\\n{failed_snapshot}\")"
|
||||
---
|
||||
routine running
|
||||
live hooks:
|
||||
<empty>
|
||||
history:
|
||||
<empty>
|
||||
|
||||
routine completed
|
||||
live hooks:
|
||||
<empty>
|
||||
history:
|
||||
<empty>
|
||||
|
||||
failed
|
||||
live hooks:
|
||||
<empty>
|
||||
history:
|
||||
• PostToolUse hook (failed)
|
||||
error: hook exited with code 7
|
||||
@@ -1483,6 +1483,7 @@ pub(super) fn hook_run(
|
||||
display_order: 0,
|
||||
status,
|
||||
status_message: Some(status_message.to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: matches!(
|
||||
status,
|
||||
|
||||
@@ -2545,6 +2545,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Running,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
@@ -2568,6 +2569,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Stopped,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
@@ -2776,6 +2778,58 @@ async fn completed_hook_with_output_flushes_immediately() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let mut routine_started = hook_started_run(
|
||||
"post-tool-use:0:/tmp/hooks.json",
|
||||
codex_app_server_protocol::HookEventName::PostToolUse,
|
||||
Some("checking output policy"),
|
||||
);
|
||||
routine_started.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden;
|
||||
handle_hook_started(&mut chat, routine_started);
|
||||
reveal_running_hooks(&mut chat);
|
||||
let routine_running_snapshot = hook_live_and_history_snapshot(&chat, "routine running", "");
|
||||
|
||||
let mut routine_completed = hook_completed_run(
|
||||
"post-tool-use:0:/tmp/hooks.json",
|
||||
codex_app_server_protocol::HookEventName::PostToolUse,
|
||||
codex_app_server_protocol::HookRunStatus::Completed,
|
||||
Vec::new(),
|
||||
);
|
||||
routine_completed.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden;
|
||||
handle_hook_completed(&mut chat, routine_completed);
|
||||
let routine_history = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
let routine_completed_snapshot =
|
||||
hook_live_and_history_snapshot(&chat, "routine completed", &routine_history);
|
||||
|
||||
let mut failed_completed = hook_completed_run(
|
||||
"post-tool-use:1:/tmp/hooks.json",
|
||||
codex_app_server_protocol::HookEventName::PostToolUse,
|
||||
codex_app_server_protocol::HookRunStatus::Failed,
|
||||
vec![codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Error,
|
||||
text: "hook exited with code 7".to_string(),
|
||||
}],
|
||||
);
|
||||
failed_completed.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden;
|
||||
handle_hook_completed(&mut chat, failed_completed);
|
||||
let failed_history = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
let failed_snapshot = hook_live_and_history_snapshot(&chat, "failed", &failed_history);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot",
|
||||
format!("{routine_running_snapshot}\n\n{routine_completed_snapshot}\n\n{failed_snapshot}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_hook_output_precedes_following_assistant_message() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -3165,6 +3219,7 @@ fn hook_run_summary(
|
||||
display_order: 0,
|
||||
status,
|
||||
status_message: status_message.map(str::to_string),
|
||||
visibility_hint: Default::default(),
|
||||
started_at: 1,
|
||||
completed_at: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(2),
|
||||
duration_ms: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(1),
|
||||
|
||||
@@ -952,6 +952,7 @@ approval_policy = "never"
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
visibility_hint: Default::default(),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
|
||||
@@ -106,6 +106,7 @@ const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000;
|
||||
mod hook_cell;
|
||||
|
||||
pub(crate) use hook_cell::HookCell;
|
||||
pub(crate) use hook_cell::hook_run_should_skip_render;
|
||||
pub(crate) use hook_cell::new_active_hook_cell;
|
||||
pub(crate) use hook_cell::new_completed_hook_cell;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use codex_app_server_protocol::HookOutputEntry;
|
||||
use codex_app_server_protocol::HookOutputEntryKind;
|
||||
use codex_app_server_protocol::HookRunStatus;
|
||||
use codex_app_server_protocol::HookRunSummary;
|
||||
use codex_app_server_protocol::HookVisibilityHint;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -679,6 +680,16 @@ pub(crate) fn new_completed_hook_cell(run: HookRunSummary, animations_enabled: b
|
||||
HookCell::new_completed(run, animations_enabled)
|
||||
}
|
||||
|
||||
/// Returns true when a hook marked as hidden has no user-relevant consequence to render.
|
||||
pub(crate) fn hook_run_should_skip_render(run: &HookRunSummary) -> bool {
|
||||
run.visibility_hint == HookVisibilityHint::Hidden
|
||||
&& match run.status {
|
||||
HookRunStatus::Running => true,
|
||||
HookRunStatus::Completed => run.entries.is_empty(),
|
||||
HookRunStatus::Blocked | HookRunStatus::Failed | HookRunStatus::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true for hook completions that should be invisible in history.
|
||||
fn hook_run_is_quiet_success(run: &HookRunSummary) -> bool {
|
||||
run.status == HookRunStatus::Completed && run.entries.is_empty()
|
||||
@@ -802,6 +813,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_hook_skips_routine_running_and_empty_completion_rows() {
|
||||
let mut run = hook_run_summary("hook-1");
|
||||
run.visibility_hint = HookVisibilityHint::Hidden;
|
||||
assert!(hook_run_should_skip_render(&run));
|
||||
|
||||
run.status = HookRunStatus::Completed;
|
||||
assert!(hook_run_should_skip_render(&run));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_hook_still_surfaces_user_relevant_outcomes() {
|
||||
let mut run = hook_run_summary("hook-1");
|
||||
run.visibility_hint = HookVisibilityHint::Hidden;
|
||||
run.status = HookRunStatus::Failed;
|
||||
assert!(!hook_run_should_skip_render(&run));
|
||||
|
||||
run.status = HookRunStatus::Blocked;
|
||||
assert!(!hook_run_should_skip_render(&run));
|
||||
|
||||
run.status = HookRunStatus::Stopped;
|
||||
assert!(!hook_run_should_skip_render(&run));
|
||||
|
||||
run.status = HookRunStatus::Completed;
|
||||
run.entries = vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: "Review this hook result".to_string(),
|
||||
}];
|
||||
assert!(!hook_run_should_skip_render(&run));
|
||||
}
|
||||
|
||||
fn hook_run_summary(id: &str) -> HookRunSummary {
|
||||
HookRunSummary {
|
||||
id: id.to_string(),
|
||||
@@ -814,6 +856,7 @@ mod tests {
|
||||
display_order: 0,
|
||||
status: HookRunStatus::Running,
|
||||
status_message: Some("checking output policy".to_string()),
|
||||
visibility_hint: HookVisibilityHint::Default,
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
|
||||
Reference in New Issue
Block a user