Add presentation hint for quiet managed hooks

This commit is contained in:
Abhinav Vedmala
2026-05-13 12:14:03 +00:00
parent 27e67a8c2a
commit c2a65edb6e
37 changed files with 465 additions and 1 deletions
@@ -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": {
@@ -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": {
@@ -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,
+2
View File
@@ -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(),
}],
},
};
+8
View File
@@ -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
View File
@@ -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()
+57
View File
@@ -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),
+1
View File
@@ -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(),
}],
},
};
+1
View File
@@ -69,6 +69,7 @@ mod tests {
timeout_sec: None,
r#async: false,
status_message: None,
visibility_hint: Default::default(),
},
],
}],
+118
View File
@@ -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,
}],
}],
);
+3
View File
@@ -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(),
}
}
+2
View File
@@ -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,
+14
View File
@@ -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()
+1
View File
@@ -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,
+1
View File
@@ -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,
+16
View File
@@ -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")]
+2
View File
@@ -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),
+6
View File
@@ -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()
@@ -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),
+1
View File
@@ -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()
+1
View File
@@ -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,