diff --git a/Cargo.lock b/Cargo.lock index bcb9a0d..8a8a5c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13305,7 +13305,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.105" +version = "0.2.106" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13394,7 +13394,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.105" +version = "0.2.106" dependencies = [ "anyhow", "clap", @@ -13656,7 +13656,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.105" +version = "0.2.106" dependencies = [ "agent-client-protocol", "anyhow", @@ -14048,7 +14048,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.105" +version = "0.2.106" dependencies = [ "semver", ] diff --git a/SOURCE_REV b/SOURCE_REV index 3306655..a92b503 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -f9736c7b86f8e1c0e99e20ebbbd1195cd0c147e3 +ba69d70c2f7d70a130a323b2becdf137af784c7f diff --git a/crates/codegen/xai-chat-state/src/actor/mod.rs b/crates/codegen/xai-chat-state/src/actor/mod.rs index 8b2be52..1789860 100644 --- a/crates/codegen/xai-chat-state/src/actor/mod.rs +++ b/crates/codegen/xai-chat-state/src/actor/mod.rs @@ -368,6 +368,9 @@ impl ChatStateActor { ChatStateCommand::GetLastAssistantText { reply } => { let _ = reply.send(self.get_last_assistant_text()); } + ChatStateCommand::GetLastAssistantTextInTurn { reply } => { + let _ = reply.send(self.get_last_assistant_text_in_turn()); + } ChatStateCommand::GetFirstUserText { reply } => { let _ = reply.send(self.get_first_user_text()); } diff --git a/crates/codegen/xai-chat-state/src/actor/queries.rs b/crates/codegen/xai-chat-state/src/actor/queries.rs index ea4de05..92b8125 100644 --- a/crates/codegen/xai-chat-state/src/actor/queries.rs +++ b/crates/codegen/xai-chat-state/src/actor/queries.rs @@ -151,6 +151,37 @@ impl ChatStateActor { }) } + /// Return the current turn's last assistant message with non-empty text, or + /// `None` when the turn produced none. + /// + /// Like [`Self::get_last_assistant_text`], but the backwards walk stops at the + /// turn boundary (a user item with `prompt_index` set, a genuine user message, + /// or a synthetic reason with [`SyntheticReason::starts_prompt_turn`]); mid-turn + /// synthetic injections are walked past. + /// + /// [`SyntheticReason::starts_prompt_turn`]: xai_grok_sampling_types::SyntheticReason::starts_prompt_turn + pub(super) fn get_last_assistant_text_in_turn(&self) -> Option { + for item in self.state.conversation.iter().rev() { + match item { + xai_grok_sampling_types::ConversationItem::Assistant(a) + if !a.content.trim().is_empty() => + { + return Some(a.content.as_ref().to_owned()); + } + xai_grok_sampling_types::ConversationItem::User(u) + if u.prompt_index.is_some() + || u.synthetic_reason + .as_ref() + .is_none_or(|r| r.starts_prompt_turn()) => + { + return None; + } + _ => {} + } + } + None + } + /// Return the text of the **first content part** of the first `User` message, /// if and only if that part is `ContentPart::Text`. /// diff --git a/crates/codegen/xai-chat-state/src/actor/tests.rs b/crates/codegen/xai-chat-state/src/actor/tests.rs index c689464..156a27e 100644 --- a/crates/codegen/xai-chat-state/src/actor/tests.rs +++ b/crates/codegen/xai-chat-state/src/actor/tests.rs @@ -2716,6 +2716,43 @@ async fn get_last_assistant_text_skips_whitespace_only() { assert_eq!(text.as_deref(), Some("real answer")); } +#[tokio::test] +async fn get_last_assistant_text_in_turn_stops_at_boundary() { + let h = TestHarness::new(); + h.handle.push_user_message(ConversationItem::user("q1")); + h.handle + .push_assistant_response(ConversationItem::assistant("previous turn answer")); + h.handle.push_user_message(ConversationItem::user("q2")); + + assert!(h.handle.get_last_assistant_text_in_turn().await.is_none()); + assert_eq!( + h.handle.get_last_assistant_text().await.as_deref(), + Some("previous turn answer"), + "the unbounded sibling still sees prior turns" + ); +} + +#[tokio::test] +async fn get_last_assistant_text_in_turn_walks_past_synthetic_injections() { + let h = TestHarness::new(); + h.handle.push_user_message(ConversationItem::user("q")); + h.handle + .push_assistant_response(ConversationItem::assistant("turn answer")); + h.handle + .push_user_message(ConversationItem::stop_hook_feedback("keep working")); + + assert_eq!( + h.handle.get_last_assistant_text_in_turn().await.as_deref(), + Some("turn answer"), + "synthetic mid-turn items must not act as turn boundaries" + ); + + // A turn-starting synthetic item (auto-wake) IS a boundary. + h.handle + .push_user_message(ConversationItem::task_completed("task done")); + assert!(h.handle.get_last_assistant_text_in_turn().await.is_none()); +} + #[tokio::test] async fn get_last_assistant_text_no_assistant_messages() { let h = TestHarness::new(); diff --git a/crates/codegen/xai-chat-state/src/commands.rs b/crates/codegen/xai-chat-state/src/commands.rs index 3f6248b..367d9b7 100644 --- a/crates/codegen/xai-chat-state/src/commands.rs +++ b/crates/codegen/xai-chat-state/src/commands.rs @@ -297,6 +297,13 @@ pub enum ChatStateCommand { reply: oneshot::Sender>, }, + /// Like `GetLastAssistantText`, but bounded to the current prompt turn: + /// returns `None` when the turn produced no assistant text (the walk stops + /// at the first turn-starting user item). + GetLastAssistantTextInTurn { + reply: oneshot::Sender>, + }, + /// Get the text of the first `Text` content part in the first `User` message. /// Returns `None` if the conversation has no user messages or the first user /// message has no text content part. @@ -461,6 +468,9 @@ mod tests { let (tx, _rx) = oneshot::channel(); let _ = ChatStateCommand::GetLastAssistantText { reply: tx }; + let (tx, _rx) = oneshot::channel(); + let _ = ChatStateCommand::GetLastAssistantTextInTurn { reply: tx }; + let (tx, _rx) = oneshot::channel(); let _ = ChatStateCommand::GetFirstUserText { reply: tx }; diff --git a/crates/codegen/xai-chat-state/src/handle.rs b/crates/codegen/xai-chat-state/src/handle.rs index 3eb0f62..c127feb 100644 --- a/crates/codegen/xai-chat-state/src/handle.rs +++ b/crates/codegen/xai-chat-state/src/handle.rs @@ -556,6 +556,20 @@ impl ChatStateHandle { .flatten() } + /// Get the current turn's last assistant message text, or `None` when the + /// turn produced none (or the actor is dead). Turn-scoped, unlike + /// [`get_last_assistant_text`], and cheaper than [`get_conversation`]. + /// + /// [`get_conversation`]: Self::get_conversation + /// [`get_last_assistant_text`]: Self::get_last_assistant_text + pub async fn get_last_assistant_text_in_turn(&self) -> Option { + self.query("GetLastAssistantTextInTurn", |reply| { + ChatStateCommand::GetLastAssistantTextInTurn { reply } + }) + .await + .flatten() + } + /// Get the text of the first `Text` content part in the first `User` message. /// /// Returns `None` if no user message with text content exists or the actor diff --git a/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs b/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs index 0a95d56..0edad8d 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs @@ -30,14 +30,27 @@ const SUPPORTED_EVENTS: &[&str] = &[ // v2 events — PascalCase and snake_case "Notification", "Stop", + "StopFailure", "UserPromptSubmit", + "PostToolUseFailure", + "PermissionDenied", "SubagentStart", + "SubagentStop", + // `SubagentEnd` is the legacy alias for `SubagentStop`. "SubagentEnd", + "PreCompact", + "PostCompact", "notification", "stop", + "stop_failure", "user_prompt_submit", + "post_tool_use_failure", + "permission_denied", "subagent_start", + "subagent_stop", "subagent_end", + "pre_compact", + "post_compact", ]; /// Parse plugin hook files with pre-filtering and env injection. diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index b585126..9c5ab03 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -229,6 +229,10 @@ pub struct RemoteSettings { pub non_git_workspace_capture: Option, #[serde(default)] pub login_shell_capture: Option, + /// When `Some(false)`, scheduled task fires run as main-conversation + /// turns instead of background subagents. + #[serde(default)] + pub scheduler_background_loops: Option, /// Release channel: `"stable"` or `"alpha"`. /// Fallback when no local `[cli] channel` or `--alpha`/`--stable` flag is set. #[serde(default)] diff --git a/crates/codegen/xai-grok-hooks/examples/README.md b/crates/codegen/xai-grok-hooks/examples/README.md index 80ff6c3..f49b46b 100644 --- a/crates/codegen/xai-grok-hooks/examples/README.md +++ b/crates/codegen/xai-grok-hooks/examples/README.md @@ -73,6 +73,20 @@ cp examples/hooks/bin/tool-logger.sh ~/.grok/hooks/bin/ chmod +x ~/.grok/hooks/bin/tool-logger.sh ``` +### 5. Stop Gate: verify before finishing (`stop-verify.json`) + +**Type:** blocking (`Stop`) + +Keeps the agent working until `cargo build` passes. A `Stop` hook runs when the agent is about to finish its turn; returning `{"decision":"block","reason":"…"}` feeds the reason back to the model and runs another round. The built-in cap ends the turn after 8 continuations. The hook sets a 300-second timeout because a timed-out Stop hook fails open and lets the agent stop. + +**Install:** +```sh +mkdir -p ~/.grok/hooks/bin +cp examples/hooks/stop-verify.json ~/.grok/hooks/ +cp examples/hooks/bin/stop-verify.sh ~/.grok/hooks/bin/ +chmod +x ~/.grok/hooks/bin/stop-verify.sh +``` + ## Format Hook files use the Claude-compatible JSON format: @@ -92,7 +106,7 @@ Hook files use the Claude-compatible JSON format: } ``` -- **Event names:** `SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd` +- **Event names:** `SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `SessionEnd` (see the [user guide](../../xai-grok-pager/docs/user-guide/10-hooks.md) for the full set) - **Matcher:** regex on tool name. Claude names like `Bash`, `Read`, `Edit` are auto-expanded to also match Grok names (`run_terminal_cmd`, `read_file`, `search_replace`) - **Timeout:** in seconds (default: 5) - **Command:** path to script (relative to hook file directory) or inline shell command @@ -101,7 +115,7 @@ Hook files use the Claude-compatible JSON format: Scripts receive the hook event envelope as JSON on **stdin** and should write a response to **stdout**: -**For blocking hooks (`PreToolUse`):** +**For tool gates (`PreToolUse`):** ```json {"decision":"allow"} ``` @@ -110,7 +124,19 @@ or {"decision":"deny","reason":"Explanation for the user"} ``` -**Exit codes:** `0` = allow, `2` = deny, other = fail-open. +**For stop gates (`Stop` / `SubagentStop`):** keep the agent working or force it to stop: +```json +{"decision":"block","reason":"Feedback fed back to the model"} +``` +```json +{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"Non-error feedback"}} +``` +```json +{"continue":false,"stopReason":"Shown to the user; overrides any block"} +``` +The turn ends after 8 consecutive continuations. The input carries `stopHookActive` (true once a block has already continued this turn) so a hook can give up. + +**Exit codes:** `0` = allow / no decision, `2` = deny (`PreToolUse`) or block-stop with stderr as the feedback, other = fail-open. Valid decision JSON on stdout wins over the exit code. **For passive hooks:** stdout is informational only. Exit `0` for success. diff --git a/crates/codegen/xai-grok-hooks/examples/hooks/bin/stop-verify.sh b/crates/codegen/xai-grok-hooks/examples/hooks/bin/stop-verify.sh new file mode 100755 index 0000000..d0492d5 --- /dev/null +++ b/crates/codegen/xai-grok-hooks/examples/hooks/bin/stop-verify.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# stop-verify.sh — keep the agent working until the build passes. +# +# A Stop hook runs when the agent is about to finish its turn. Emitting a +# block decision feeds the reason back to the model and runs another round; +# the built-in cap ends the turn after 8 continuations. Set a generous +# timeout on the hook (see stop-verify.json), since a timed-out hook fails +# open and lets the agent stop. + +INPUT=$(cat) + +# Gate only genuine turn ends, not the observe-only session-end fire. +REASON=$(echo "$INPUT" | grep -o '"reason":"[^"]*"' | sed 's/"reason":"//;s/"$//') +if [ "$REASON" != "end_turn" ]; then + exit 0 +fi + +if cargo build --quiet >/dev/null 2>&1; then + # Build is green: allow the stop. + exit 0 +fi + +# Build is red: keep the agent working, with the failure as feedback. +echo '{"decision":"block","reason":"cargo build failed; fix the errors before finishing."}' diff --git a/crates/codegen/xai-grok-hooks/examples/hooks/stop-verify.json b/crates/codegen/xai-grok-hooks/examples/hooks/stop-verify.json new file mode 100644 index 0000000..d1d826c --- /dev/null +++ b/crates/codegen/xai-grok-hooks/examples/hooks/stop-verify.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "bin/stop-verify.sh", "timeout": 300 } + ] + } + ] + } +} diff --git a/crates/codegen/xai-grok-hooks/src/config.rs b/crates/codegen/xai-grok-hooks/src/config.rs index b713fca..74a31c7 100644 --- a/crates/codegen/xai-grok-hooks/src/config.rs +++ b/crates/codegen/xai-grok-hooks/src/config.rs @@ -9,18 +9,15 @@ use crate::matcher::HookMatcher; /// The parsed `hooks` object from a compatible JSON settings file. /// -/// Parsing is lenient: unrecognized event names are skipped (not errors) -/// so that `~/.claude/settings.json` files with unsupported events don't -/// break loading of the events we do support. +/// Parsing is lenient: unrecognized event names are skipped (not errors) so a +/// `~/.claude/settings.json` with unsupported events still loads the rest. #[derive(Debug)] pub struct HooksMap { pub events: HashMap>, - /// Event names present in the JSON but not recognized by Grok. pub skipped_events: Vec, } impl HooksMap { - /// Parse a `hooks` JSON value. Unrecognized event names are skipped. pub fn from_value(value: serde_json::Value) -> Result { let raw_map: HashMap = serde_json::from_value(value).map_err(|e| format!("invalid hooks structure: {e}"))?; @@ -45,7 +42,9 @@ impl HooksMap { } }; - events.insert(event_name, matcher_groups); + // Aliases (e.g. `SubagentEnd`) can parse to one event, so merge + // groups rather than insert, which would drop all but one. + events.entry(event_name).or_default().extend(matcher_groups); } Ok(HooksMap { @@ -55,55 +54,30 @@ impl HooksMap { } } -/// A matcher group: an optional matcher pattern and one or more hook handlers. #[derive(Debug, Deserialize)] pub struct MatcherGroup { - /// Regex pattern to filter tool names (e.g. `"Bash"`, `"Edit|Write"`). - /// Empty string or absent means match all. #[serde(default)] pub matcher: Option, - /// Array of hook handlers to run when matched. pub hooks: Vec, } -/// A single hook handler entry in the JSON format. #[derive(Debug, Deserialize)] pub struct RawHandler { - /// Handler type: `"command"` or `"http"`. #[serde(rename = "type")] pub handler_type: String, - /// Path to the executable script/binary (for `"command"` handlers). pub command: Option, - /// URL endpoint (for `"http"` handlers). pub url: Option, - /// Timeout in seconds (settings-file format). Converted to milliseconds internally. + /// Seconds (converted to milliseconds internally). pub timeout: Option, - /// Optional extra environment variables to inject into the hook process. - /// Compatible with common agent settings. These are merged into [`HookSpec::extra_env`] and - /// also feed the load-time env-var expansion of `command` and `url`. - /// Plugin-injected vars (set by the plugin adapter) override these for - /// the keys the plugin owns (e.g. `CLAUDE_PLUGIN_ROOT`); see the rustdoc - /// on [`HookSpec::extra_env`]. User attempts to set runner-reserved - /// keys (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, - /// `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`) are stripped at load - /// time and a warning is logged. - /// - /// `serde(default)` so that omitting `env` from the JSON gives an - /// empty map (no extra env). `null` is also tolerated and yields - /// the same empty map (see `parse_hook_file_env_null_treated_as_empty`). - /// JSON values that aren't strings (e.g. `"PORT": 8080`) trigger a - /// serde error -- see `parse_hook_file_env_value_must_be_string` - /// for the documented failure. + /// Extra env vars for the hook process; merged into [`HookSpec::extra_env`] + /// (see its rustdoc for precedence and reserved-key stripping). #[serde(default, deserialize_with = "deserialize_optional_string_map")] pub env: HashMap, } -/// Custom deserializer that accepts `null`, an absent field, or a -/// string-keyed map of string values. Used for `RawHandler::env`. -/// -/// Without this, `serde` rejects an explicit `"env": null` JSON value -/// for a `HashMap` field even with `#[serde(default)]`. -/// Treating `null` as "no env" matches the user's likely intent. +/// Accepts `null`, an absent field, or a string map. Serde otherwise rejects an +/// explicit `"env": null` for a `HashMap` field even with `#[serde(default)]`; +/// treating `null` as "no env" matches user intent. fn deserialize_optional_string_map<'de, D>(de: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -112,127 +86,102 @@ where Ok(opt.unwrap_or_default()) } -/// Default timeout in seconds when not specified. pub const DEFAULT_TIMEOUT_SECS: u64 = 5; -/// Default timeout in milliseconds (derived from DEFAULT_TIMEOUT_SECS). pub const DEFAULT_TIMEOUT_MS: u64 = DEFAULT_TIMEOUT_SECS * 1000; +/// Stop gates run real verification (builds, tests) and fail open on timeout, so +/// the short observe default would silently disable a ported stop policy. +pub const DEFAULT_STOP_GATE_TIMEOUT_SECS: u64 = 600; + +pub const DEFAULT_STOP_GATE_TIMEOUT_MS: u64 = DEFAULT_STOP_GATE_TIMEOUT_SECS * 1000; + +fn default_timeout_ms(event: crate::event::HookEventName) -> u64 { + if event.traits().gate == crate::event::GateKind::Stop { + DEFAULT_STOP_GATE_TIMEOUT_MS + } else { + DEFAULT_TIMEOUT_MS + } +} + +/// The validated handler kind. `RawHandler::handler_type` keeps the untrusted +/// string; parsing validates it into this so consumers dispatch exhaustively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HandlerType { + Command, + Http, +} + +impl HandlerType { + pub fn as_str(self) -> &'static str { + match self { + Self::Command => "command", + Self::Http => "http", + } + } +} + /// A validated hook specification, ready for use by the dispatcher. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HookSpec { pub name: String, pub event: HookEventName, - /// Handler type: `"command"` or `"http"`. - pub handler_type: String, - /// The configured matcher pattern as written in the JSON file (e.g. `"Bash"`). - /// Used for display in `/hooks-list`. Separate from the compiled matcher - /// (which applies compat alias expansion and matching). + pub handler_type: HandlerType, + /// Raw pattern as written, kept for `/hooks-list` display (the compiled form + /// is [`matcher`](HookSpec::matcher)). pub configured_matcher: Option, - /// The compiled matcher (exact for simple patterns, alias-expanded, - /// unanchored regex otherwise). #[serde(skip)] pub matcher: Option, pub enabled: bool, - /// Path to the executable (for `"command"` handlers). `None` for other types. - /// - /// **Post-expansion form.** `${VAR}` / `$VAR` references that were - /// resolvable at parse time have been substituted via - /// [`crate::env_expand::expand_env_vars_with_extra`] using the - /// user-supplied `env` map plus the process environment. Unresolved - /// references and parameter-expansion-modifier forms (`${VAR:-x}`, - /// `${VAR%pat}`, etc.) are preserved verbatim and resolved at run - /// time by the runner's `sh -c` branch. - /// - /// **Asymmetry vs `url`.** Command paths are NOT re-expanded at run - /// time: the runtime `sh -c` branch picks up mid-session env - /// changes for commands containing shell metacharacters, but - /// direct-exec paths see only the parse-time snapshot. URLs ARE - /// re-expanded at runtime by the HTTP runner (see [`url`]). - /// - /// **Source preservation.** Use [`command_raw`] for display so the - /// pager UI / ACP DTO never leaks resolved secret values from the - /// `env` map into log files or the modal. - /// - /// [`url`]: HookSpec::url - /// [`command_raw`]: HookSpec::command_raw + /// Executable path (command handlers), post-expansion: parse-time-resolvable + /// `$VAR` refs are substituted, unresolved/modifier forms (`${VAR:-x}`) kept + /// for the runner's `sh -c` branch. Unlike [`url`](HookSpec::url), commands + /// are NOT re-expanded at run time, so only `sh -c` sees mid-session env + /// changes. Display via [`command_raw`](HookSpec::command_raw) so resolved + /// secrets never leak. pub command: Option, - /// Pre-expansion source string for `command`, exactly as written in - /// the JSON file. `None` for non-command handlers and for hooks - /// loaded by older code paths that pre-date the raw-source field. - /// Use this in any display surface (pager UI, ACP DTO, tracing - /// logs) so resolved env-var values from the user `env` map -- some - /// of which may be secrets -- never leak past the runner. + /// Pre-expansion source for `command`; use it for display so resolved `env` + /// values (possibly secrets) never leak past the runner. pub command_raw: Option, - /// URL endpoint (for `"http"` handlers). `None` for other types. - /// - /// **Post-expansion form** at parse time, with the same semantics - /// as [`command`]. The HTTP runner additionally re-expands this - /// field at run time before SSRF validation, so plugin URLs that - /// reference `extra_env` keys injected after parsing (e.g. - /// `${CLAUDE_PLUGIN_ROOT}/check`) resolve correctly. This means - /// mid-session changes to process env DO take effect for URLs but - /// NOT for commands -- a deliberate asymmetry; document any user - /// expectation accordingly. - /// - /// **Source preservation.** Use [`url_raw`] for display. - /// - /// [`command`]: HookSpec::command - /// [`url_raw`]: HookSpec::url_raw + /// URL endpoint (http handlers), post-expanded like [`command`](HookSpec::command). + /// The HTTP runner re-expands it at run time before SSRF validation, so plugin + /// URLs referencing later-injected `extra_env` keys resolve: mid-session env + /// changes take effect for URLs but not commands (deliberate asymmetry). + /// Display via [`url_raw`](HookSpec::url_raw). pub url: Option, - /// Pre-expansion source string for `url`, exactly as written in the - /// JSON file. `None` for non-HTTP handlers. See [`command_raw`]. - /// - /// [`command_raw`]: HookSpec::command_raw + /// Pre-expansion source for `url`, for display; see [`command_raw`](HookSpec::command_raw). pub url_raw: Option, pub timeout_ms: u64, - /// The directory containing the JSON file that defined this hook. - /// Used for resolving relative command paths. pub source_dir: PathBuf, /// Extra environment variables injected into the hook process. /// - /// Sources, listed lowest to highest precedence: + /// Sources, lowest to highest precedence: /// - /// 1. The user-declared `env` map on the JSON `RawHandler` (populated - /// by [`parse_hook_file`]). Runner-reserved keys - /// (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, - /// `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`) are stripped at - /// load time and a tracing warning is emitted. - /// 2. Plugin-injected vars merged in by the plugin adapter - /// (`xai-grok-agent::plugins::hooks_adapter`). The adapter sets - /// `GROK_PLUGIN_ROOT`, `CLAUDE_PLUGIN_ROOT`, `GROK_PLUGIN_DATA`, - /// and `CLAUDE_PLUGIN_DATA`; the merge overrides any user - /// values for those four keys so the plugin contract is intact. - /// 3. Runner-injected vars at spawn time - /// (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, - /// `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`). These are - /// applied AFTER `extra_env` in the spawn call so they always - /// win, even if the layered defenses above leak a reserved key - /// through. This is a security property: the spawned child - /// must always see authentic identity/event signals, never - /// user-controlled spoofed values. See the regression test + /// 1. The user-declared `env` map (populated by [`parse_hook_file`]). + /// Runner-reserved keys (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, + /// `GROK_SESSION_ID`, `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`) are + /// stripped at load time with a tracing warning. + /// 2. Plugin-injected vars merged by the plugin adapter + /// (`xai-grok-agent::plugins::hooks_adapter`): `GROK_PLUGIN_ROOT`, + /// `CLAUDE_PLUGIN_ROOT`, `GROK_PLUGIN_DATA`, `CLAUDE_PLUGIN_DATA`, which + /// override any user values for those keys. + /// 3. Runner-injected vars applied at spawn time AFTER `extra_env`, so they + /// always win even if a reserved key leaks through the layers above. This + /// is a security property: the child must see authentic identity/event + /// signals, never spoofed values. See the regression test /// `runner_injected_vars_override_extra_env_at_spawn` in /// `tests/integration.rs`. /// - /// In addition to being passed to the spawned child process, this map - /// is consulted by the load-time `${VAR}` / `$VAR` expansion of - /// `command` and `url` (see [`crate::env_expand`]). + /// Besides being passed to the child, this map is consulted by the load-time + /// expansion of `command` and `url` (see [`crate::env_expand`]). pub extra_env: std::collections::HashMap, } -/// Parse and validate a hook file from its JSON content. -/// -/// Accepts any JSON file (settings file, dedicated hook file, etc.). -/// Extracts only the `hooks` key from the top level. All other keys are -/// ignored, so this works with settings files that contain -/// theme, model, permission, and other unrelated configuration. -/// -/// Returns the list of validated hook specs and any non-fatal errors -/// (invalid entries are skipped with errors collected). /// Parse hooks from a JSON value (e.g. from agent definition frontmatter). /// -/// `source_dir` is used to resolve relative command paths in hook specs. -/// Pass the agent definition's directory or the workspace CWD. +/// `source_dir` resolves relative command paths: pass the agent definition's +/// directory or the workspace CWD. pub fn parse_hooks_from_value( hooks: &serde_json::Value, source_name: &str, @@ -250,8 +199,6 @@ pub fn parse_hooks_from_value_with_dir( let wrapper = serde_json::json!({ "hooks": hooks }); let (mut specs, errors) = parse_hook_file(&wrapper.to_string(), std::path::Path::new(source_name)); - // Override the source_dir (which parse_hook_file derived from the fake - // source_name path) with the real directory. for spec in &mut specs { spec.source_dir = source_dir.to_path_buf(); } @@ -262,7 +209,6 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec v, Err(e) => { @@ -274,10 +220,9 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec v.clone(), - None => return (specs, errors), // No hooks key — not an error, just no hooks. + None => return (specs, errors), }; let hooks_map: HooksMap = match HooksMap::from_value(hooks_value) { @@ -305,31 +250,29 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec match HookMatcher::new(pattern) { Ok(m) => Some(m), Err(e) => { @@ -348,107 +291,20 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec = handler.env; strip_reserved_env_keys(&mut extra_env, &name, file_path); - match handler.handler_type.as_str() { - "command" => { - let Some(command) = handler.command else { - errors.push(HookError::InvalidConfig { - name, - path: file_path.to_path_buf(), - detail: "command handler requires a 'command' field".into(), - }); - continue; - }; - // Env-expand `command` at config-load time using the - // hook's own `extra_env` first, then process env. This - // makes direct-exec command paths that use `$VAR` / - // `${VAR}` references work without depending on the - // runtime `sh -c` heuristic in the runner. Unset - // refs (e.g. `${SOMETHING_SET_AT_RUN_TIME}`) are - // preserved verbatim and handled by the runner's - // pre-flight check (see `crate::runner::command`). - let expanded_command = - crate::env_expand::expand_env_vars_with_extra(&command, &extra_env); - specs.push(HookSpec { - name, - event, - handler_type: "command".into(), - configured_matcher: matcher_pattern.clone(), - matcher: compiled_matcher.clone(), - enabled: true, - command: Some(PathBuf::from(expanded_command)), - command_raw: Some(command), - url: None, - url_raw: None, - timeout_ms, - source_dir: source_dir.clone(), - extra_env, - }); - } - "http" => { - let Some(url) = handler.url else { - errors.push(HookError::InvalidConfig { - name, - path: file_path.to_path_buf(), - detail: "http handler requires a 'url' field".into(), - }); - continue; - }; - // Env-expand `url` at config-load time. Unset refs - // are preserved; the HTTP runner re-runs expansion - // immediately before SSRF validation in case - // `extra_env` was populated after parsing (e.g. by - // the plugin adapter). - let expanded_url = - crate::env_expand::expand_env_vars_with_extra(&url, &extra_env); - specs.push(HookSpec { - name, - event, - handler_type: "http".into(), - configured_matcher: matcher_pattern.clone(), - matcher: compiled_matcher.clone(), - enabled: true, - command: None, - command_raw: None, - url: Some(expanded_url), - url_raw: Some(url), - timeout_ms, - source_dir: source_dir.clone(), - extra_env, - }); - } + let handler_type = match handler.handler_type.as_str() { + "command" => HandlerType::Command, + "http" => HandlerType::Http, _ => { errors.push(HookError::UnsupportedHandlerType { name, @@ -457,7 +313,56 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec { + let Some(command) = handler.command else { + errors.push(HookError::InvalidConfig { + name, + path: file_path.to_path_buf(), + detail: "command handler requires a 'command' field".into(), + }); + continue; + }; + let expanded = + crate::env_expand::expand_env_vars_with_extra(&command, &extra_env); + (Some(PathBuf::from(expanded)), Some(command), None, None) + } + HandlerType::Http => { + let Some(url) = handler.url else { + errors.push(HookError::InvalidConfig { + name, + path: file_path.to_path_buf(), + detail: "http handler requires a 'url' field".into(), + }); + continue; + }; + let expanded = + crate::env_expand::expand_env_vars_with_extra(&url, &extra_env); + (None, None, Some(expanded), Some(url)) + } + }; + + specs.push(HookSpec { + name, + event, + handler_type, + configured_matcher: matcher_pattern.clone(), + matcher: compiled_matcher.clone(), + enabled: true, + command, + command_raw, + url, + url_raw, + timeout_ms, + source_dir: source_dir.clone(), + extra_env, + }); } } } @@ -465,15 +370,11 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec, spec_name: &str, @@ -517,7 +418,7 @@ mod tests { assert_eq!(s.event, HookEventName::PreToolUse); assert!(s.matcher.is_some()); assert!(s.enabled); - assert_eq!(s.timeout_ms, 2000); // 2 seconds → 2000 ms + assert_eq!(s.timeout_ms, 2000); assert_eq!(s.command, Some(PathBuf::from("bin/check.sh"))); } @@ -554,7 +455,7 @@ mod tests { }"#; let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); assert!(errors.is_empty()); - assert!(specs[0].matcher.is_none()); // empty string → None → match all + assert!(specs[0].matcher.is_none()); } #[test] @@ -577,30 +478,97 @@ mod tests { "hooks": { "SessionEnd": [ { "hooks": [{ "type": "command", "command": "end.sh" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "verify.sh" }] } + ], + "SubagentStop": [ + { "hooks": [{ "type": "command", "command": "sub.sh" }] } ] } }"#; let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); assert!(errors.is_empty()); - assert_eq!(specs[0].timeout_ms, DEFAULT_TIMEOUT_MS); + for spec in &specs { + let expected = match spec.event { + HookEventName::Stop | HookEventName::SubagentStop => DEFAULT_STOP_GATE_TIMEOUT_MS, + _ => DEFAULT_TIMEOUT_MS, + }; + assert_eq!(spec.timeout_ms, expected, "event {}", spec.event); + } } #[test] - fn reject_lifecycle_hook_with_matcher() { + fn session_start_matcher_compiles_and_tests_source() { let json = r#"{ "hooks": { "SessionStart": [ - { "matcher": "something", "hooks": [{ "type": "command", "command": "s.sh" }] } + { "matcher": "startup|resume", "hooks": [{ "type": "command", "command": "s.sh" }] } ] } }"#; let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); - assert!(specs.is_empty()); - assert_eq!(errors.len(), 1); - assert!(matches!( - &errors[0], - HookError::LifecycleMatcherNotAllowed { .. } - )); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert_eq!(specs.len(), 1); + let matcher = specs[0].matcher.as_ref().expect("matcher compiles"); + assert!(matcher.is_match("startup")); + assert!(!matcher.is_match("clear")); + } + + #[test] + fn alias_event_keys_merge_groups() { + let json = r#"{ + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": "a.sh" }] } + ], + "stop": [ + { "hooks": [{ "type": "command", "command": "b.sh" }] } + ] + } + }"#; + let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); + assert!(errors.is_empty()); + assert_eq!(specs.len(), 2, "both groups must survive the key collision"); + } + + #[test] + fn stop_matcher_ignored_subagent_stop_matcher_kept() { + let json = r#"{ + "hooks": { + "Stop": [ + { "matcher": "*", "hooks": [{ "type": "command", "command": "s.sh" }] } + ], + "SubagentStop": [ + { "matcher": "code-reviewer", "hooks": [{ "type": "command", "command": "r.sh" }] } + ] + } + }"#; + let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); + assert!(errors.is_empty(), "no load errors expected: {errors:?}"); + assert_eq!(specs.len(), 2); + + let stop = specs + .iter() + .find(|s| s.command_raw.as_deref() == Some("s.sh")) + .unwrap(); + assert!(stop.matcher.is_none(), "Stop matcher must not compile"); + assert_eq!( + stop.configured_matcher.as_deref(), + Some("*"), + "the configured pattern stays visible for display" + ); + + let sub = specs + .iter() + .find(|s| s.command_raw.as_deref() == Some("r.sh")) + .unwrap(); + assert!( + sub.matcher + .as_ref() + .is_some_and(|m| m.is_match("code-reviewer")), + "SubagentStop matcher must be compiled and match its agent type" + ); } #[test] @@ -657,7 +625,7 @@ mod tests { let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); assert!(errors.is_empty()); assert_eq!(specs.len(), 1); - assert_eq!(specs[0].handler_type, "http"); + assert_eq!(specs[0].handler_type, HandlerType::Http); assert!(specs[0].command.is_none()); assert_eq!( specs[0].url.as_deref(), @@ -706,8 +674,6 @@ mod tests { #[test] fn realistic_claude_settings_file() { - // A realistic settings.json with many unrelated keys and - // deeply nested non-hook structures. let json = r#"{ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { @@ -752,7 +718,6 @@ mod tests { let (specs, errors) = parse_hook_file(json, Path::new("/home/user/.claude/settings.json")); assert!(errors.is_empty(), "errors: {errors:?}"); assert_eq!(specs.len(), 2); - // Both events should be present regardless of HashMap order. let has_pre = specs.iter().any(|s| s.event == HookEventName::PreToolUse); let has_post = specs.iter().any(|s| s.event == HookEventName::PostToolUse); assert!(has_pre, "expected PreToolUse hook"); @@ -792,30 +757,9 @@ mod tests { assert!(has_post, "expected PostToolUse hook"); } - #[test] - fn lenient_parsing_skips_all_unknown_events() { - let json = r#"{ - "hooks": { - "PermissionRequest": [ - { "hooks": [{ "type": "command", "command": "perm.sh" }] } - ], - "ConfigChange": [ - { "hooks": [{ "type": "command", "command": "config.sh" }] } - ], - "WorktreeCreate": [ - { "hooks": [{ "type": "command", "command": "wt.sh" }] } - ] - } - }"#; - let (specs, errors) = parse_hook_file(json, Path::new("/tmp/settings.json")); - assert!(errors.is_empty(), "unexpected errors: {errors:?}"); - assert!(specs.is_empty(), "expected no specs from unknown events"); - } - - /// Regression: a JSON hook whose `command` references an env var that - /// IS set in the process environment must be expanded at config-load - /// time. This removes the dependence on the runtime `sh -c` heuristic - /// for direct-exec command paths that have no other shell metachars. + /// A `command` referencing a process-env var must be expanded at load time, + /// removing the dependence on the runtime `sh -c` heuristic for direct-exec + /// paths with no other shell metachars. #[test] fn parse_hook_file_expands_env_var_in_command_from_process_env() { let key = "GROK_HOOKS_PARSE_TEST_CMD_PROC_ENV"; @@ -833,8 +777,6 @@ mod tests { assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(specs.len(), 1); assert_eq!(specs[0].command, Some(PathBuf::from("/usr/local/check.sh"))); - // The raw form must preserve the original reference so the - // pager UI / ACP DTO surface the source string. assert_eq!( specs[0].command_raw.as_deref(), Some(format!("${{{key}}}/check.sh").as_str()) @@ -842,9 +784,8 @@ mod tests { }); } - /// Regression: a JSON HTTP hook whose `url` references an env var that - /// IS set in the process environment must have the var substituted at - /// config-load time so SSRF validation sees the resolved host. + /// An HTTP `url` referencing a process-env var must be substituted at load + /// time so SSRF validation sees the resolved host. #[test] fn parse_hook_file_expands_env_var_in_url_from_process_env() { let key = "GROK_HOOKS_PARSE_TEST_URL_PROC_ENV"; @@ -865,7 +806,6 @@ mod tests { specs[0].url.as_deref(), Some("https://hooks.example.com/check") ); - // url_raw preserves the source. assert_eq!( specs[0].url_raw.as_deref(), Some(format!("https://${{{key}}}/check").as_str()) @@ -873,9 +813,8 @@ mod tests { }); } - /// Regression: a JSON hook may declare an `env` map that gets injected - /// into the spawned process via `HookSpec::extra_env`. This is the - /// compatible-settings feature for non-plugin hooks. + /// A declared `env` map is injected into the process via + /// `HookSpec::extra_env`. #[test] fn parse_hook_file_env_map_populates_extra_env() { let json = r#"{ @@ -896,8 +835,6 @@ mod tests { let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(specs.len(), 1); - // Lock down exact map size so a regression that - // accidentally injects extra keys would fail. assert_eq!(specs[0].extra_env.len(), 2); assert_eq!( specs[0].extra_env.get("FOO").map(String::as_str), @@ -909,10 +846,8 @@ mod tests { ); } - /// Regression: a JSON hook whose `env` map provides a value for a var - /// referenced in `command` must use that value (not the process env) - /// when expanding the command at load time. This proves that the - /// per-hook `env` map feeds back into load-time expansion. + /// An `env` map value for a var referenced in `command` must win over the + /// process env when expanding at load time. #[test] fn parse_hook_file_env_map_feeds_command_expansion() { let json = r#"{ @@ -937,7 +872,6 @@ mod tests { specs[0].command, Some(PathBuf::from("/from/env-map/check.sh")) ); - // Lock down exact map size. assert_eq!(specs[0].extra_env.len(), 1); assert_eq!( specs[0].extra_env.get("MY_HOOK_ROOT").map(String::as_str), @@ -945,12 +879,9 @@ mod tests { ); } - /// Regression: a JSON hook whose `command` references a var that is - /// NOT set anywhere at config-load time must preserve the literal - /// `${VAR}` text. The runner's pre-flight check is the single source - /// of truth for "is this resolvable at run time?". Load-time - /// expansion must therefore be idempotent (a no-op on already - /// expanded strings) so that the runtime check is never bypassed. + /// A `command` referencing a var unset at load time must preserve the + /// literal `${VAR}`, so the runner's pre-flight check stays the single + /// source of truth for run-time resolvability. #[test] fn parse_hook_file_preserves_unresolved_env_refs_in_command() { let key = "GROK_HOOKS_PARSE_TEST_NEVER_SET_AT_LOAD_TIME"; @@ -967,9 +898,6 @@ mod tests { let (specs, errors) = parse_hook_file(&json, Path::new("/tmp/test.json")); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(specs.len(), 1); - // Lock down both halves with assert_eq! so a - // regression that strips the trailing `/x.sh` would also - // be caught. let cmd = specs[0] .command .as_ref() @@ -1003,26 +931,8 @@ mod tests { }); } - /// Default for `extra_env` is an empty map when the JSON has no `env`. - /// Guarantees we don't accidentally populate keys. - #[test] - fn parse_hook_file_extra_env_defaults_empty() { - let json = r#"{ - "hooks": { - "PreToolUse": [ - { "hooks": [{ "type": "command", "command": "echo hi" }] } - ] - } - }"#; - let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); - assert!(errors.is_empty(), "unexpected errors: {errors:?}"); - assert_eq!(specs.len(), 1); - assert!(specs[0].extra_env.is_empty()); - } - - /// Explicit `"env": null` must be tolerated and yield an - /// empty extra_env map -- documented behaviour rather than serde's - /// default failure mode. + /// Explicit `"env": null` is tolerated and yields an empty `extra_env` map, + /// rather than serde's default failure mode. #[test] fn parse_hook_file_env_null_treated_as_empty() { let json = r#"{ @@ -1042,9 +952,8 @@ mod tests { assert!(specs[0].extra_env.is_empty()); } - /// Env values are stored verbatim; references inside them - /// (e.g. `"${HOME}/x"`) are NOT recursively expanded. This documents - /// the contract -- the env map is plumbing, not a templating layer. + /// Env values are stored verbatim: references inside them (e.g. `"${HOME}/x"`) + /// are NOT recursively expanded. The env map is plumbing, not a template layer. #[test] fn parse_hook_file_env_values_are_stored_verbatim() { let json = r#"{ @@ -1072,24 +981,11 @@ mod tests { ); } - /// `matcher` is intentionally NOT env-expanded. A - /// matcher with `$VAR` must store the literal `$VAR` (anchored as - /// part of the regex by `HookMatcher::new`). A future contributor - /// adding "completeness" here would break regex semantics. #[test] fn parse_hook_file_matcher_is_not_env_expanded() { let key = "GROK_HOOKS_PARSE_TEST_MATCHER_VAR"; with_env_var(key, Some("expanded_value_should_not_appear"), || { - // Use a regex-valid matcher pattern that also embeds `$KEY`. - // We deliberately use a pattern that's a valid regex even - // without expansion (`$` in regex is the end-of-line - // anchor, so `^foo$KEY$` is a valid pattern that matches - // literally nothing but parses). let pattern = format!("foo{key}"); - // Wrap in a JSON-safe regex: the value `foo$VARNAME` is a - // valid regex (the `$` anchors before `V` -- literal char - // class). We just want to prove the stored value contains - // no expansion. let json = serde_json::json!({ "hooks": { "PreToolUse": [ @@ -1105,12 +1001,10 @@ mod tests { let (specs, errors) = parse_hook_file(&json.to_string(), Path::new("/tmp/test.json")); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(specs.len(), 1); - // configured_matcher stores the source string verbatim. assert_eq!( specs[0].configured_matcher.as_deref(), Some(pattern.as_str()) ); - // The string value must NOT contain the expansion. let stored = specs[0].configured_matcher.as_deref().unwrap_or(""); assert!( !stored.contains("expanded_value_should_not_appear"), @@ -1119,52 +1013,9 @@ mod tests { }); } - /// Same property for the `${VAR}` form. - #[test] - fn parse_hook_file_matcher_braced_var_is_not_env_expanded() { - // Build a matcher that is unambiguously - // VALID regex regardless of whether expansion occurred. Using - // a character class `[${KEY}]_tool` works because `$` is - // trivially valid as a literal inside `[...]` (it loses its - // anchor meaning), and `{`/`}` inside a character class are - // also literals (not quantifier metachars). So whichever - // string actually lands in the matcher, regex compilation - // succeeds. This lets us assert on the single - // successful-compile path with `assert_eq!(specs.len(), 1)` - // and a single `assert!(!stored.contains(...))`. - let key = "GROK_HOOKS_PARSE_TEST_MATCHER_BRACED"; - with_env_var(key, Some("expanded_should_not_appear"), || { - let pattern = format!("[${{{key}}}]_tool"); - let json = serde_json::json!({ - "hooks": { - "PreToolUse": [ - { - "matcher": pattern, - "hooks": [ - { "type": "command", "command": "echo hi" } - ] - } - ] - } - }); - let (specs, errors) = parse_hook_file(&json.to_string(), Path::new("/tmp/test.json")); - assert!(errors.is_empty(), "unexpected errors: {errors:?}"); - assert_eq!(specs.len(), 1); - let stored = specs[0].configured_matcher.as_deref().unwrap_or(""); - assert!( - !stored.contains("expanded_should_not_appear"), - "matcher must NOT be env-expanded, got {stored:?}" - ); - // Stored value must equal the source pattern verbatim. - assert_eq!(stored, pattern); - }); - } - - /// A non-string `env` value (e.g. `"PORT": 8080`) currently - /// fails deserialization with a serde error. Document the failure - /// mode and ensure the parse error is reported (not silently - /// dropped). Users who need numeric values must wrap them in - /// strings (`"PORT": "8080"`). + /// A non-string `env` value (e.g. `"PORT": 8080`) fails deserialization; the + /// whole file surfaces a `ParseFile` error rather than silently dropping it. + /// Users who need numeric values must quote them (`"PORT": "8080"`). #[test] fn parse_hook_file_env_value_must_be_string() { let json = r#"{ @@ -1183,13 +1034,6 @@ mod tests { } }"#; let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); - // The parse error currently surfaces as a `ParseFile` error - // from `serde_json::from_value` because `RawHandler` deserialises - // env values as strings. The whole file fails to parse, which - // means no specs come back. This is the documented failure - // mode -- the alternative (stringifying numbers) requires a - // custom deserializer that we can revisit if the constraint - // becomes a real pain point in practice. assert!( specs.is_empty(), "expected non-string env value to fail parsing" @@ -1198,12 +1042,6 @@ mod tests { !errors.is_empty(), "expected an error for non-string env value, got none" ); - // Lock the error variant. The non-string - // env value should surface as `HookError::ParseFile` (the - // top-level matcher-group deserialization fails when serde - // hits the typed `env` field), NOT as some generic - // `InvalidConfig` or stub error -- which would mask future - // regressions in error reporting. assert!( errors .iter() @@ -1212,11 +1050,8 @@ mod tests { ); } - /// User attempts to set runner-reserved keys (GROK_HOOK_*, - /// GROK_SESSION_ID, GROK_WORKSPACE_ROOT, CLAUDE_PROJECT_DIR) via - /// the JSON `env` map are stripped at load time. Spawn-time - /// precedence ordering also overrides these keys, but stripping - /// here gives users a clear "ignored" signal. + /// User attempts to set runner-reserved keys via the `env` map are stripped + /// at load time, giving a clear "ignored" signal on top of spawn-time override. #[test] fn parse_hook_file_strips_runner_reserved_env_keys() { let json = r#"{ @@ -1244,7 +1079,6 @@ mod tests { let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(specs.len(), 1); - // All five reserved keys must be stripped. for reserved in [ "GROK_HOOK_EVENT", "GROK_HOOK_NAME", @@ -1258,36 +1092,10 @@ mod tests { specs[0].extra_env ); } - // User-declared non-reserved key survives. assert_eq!( specs[0].extra_env.get("USER_KEY").map(String::as_str), Some("kept") ); assert_eq!(specs[0].extra_env.len(), 1); } - - #[test] - fn handler_with_extra_claude_fields() { - let json = r#"{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "check.sh", - "timeout": 5, - "allowedEnvVars": ["API_KEY"], - "someOtherField": true - } - ] - } - ] - } - }"#; - let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json")); - assert!(errors.is_empty(), "errors: {errors:?}"); - assert_eq!(specs.len(), 1); - } } diff --git a/crates/codegen/xai-grok-hooks/src/discovery.rs b/crates/codegen/xai-grok-hooks/src/discovery.rs index 462f5bb..5c34f43 100644 --- a/crates/codegen/xai-grok-hooks/src/discovery.rs +++ b/crates/codegen/xai-grok-hooks/src/discovery.rs @@ -18,29 +18,51 @@ pub struct HookRegistry { } impl HookRegistry { - /// Returns the hooks registered for the given event type. + /// Hooks registered under the exact event key. Use + /// [`Self::hooks_for_canonical`] for dispatch. pub fn hooks_for(&self, event: HookEventName) -> &[HookSpec] { self.hooks.get(&event).map(|v| v.as_slice()).unwrap_or(&[]) } - /// Returns true if the registry contains no hooks at all. + /// Returns true when any enabled hook is registered for `event` or its + /// alias spelling. Allocation-free guard for hot paths. + pub fn has_enabled_hooks_for_canonical(&self, event: HookEventName) -> bool { + let enabled = |specs: &[HookSpec]| { + specs + .iter() + .any(|s| s.enabled && !crate::trust::is_hook_disabled(&s.name)) + }; + let canonical = event.canonical(); + enabled(self.hooks_for(canonical)) + || (canonical == HookEventName::SubagentStop + && enabled(self.hooks_for(HookEventName::SubagentEnd))) + } + + /// Hooks for `event` plus any registered under an alias spelling + /// (`SubagentEnd` ≡ `SubagentStop`), so dispatch treats both identically. + pub fn hooks_for_canonical(&self, event: HookEventName) -> Vec<&HookSpec> { + let canonical = event.canonical(); + let mut out: Vec<&HookSpec> = self.hooks_for(canonical).iter().collect(); + if canonical == HookEventName::SubagentStop { + out.extend(self.hooks_for(HookEventName::SubagentEnd)); + } + out + } + pub fn is_empty(&self) -> bool { self.hooks.values().all(|v| v.is_empty()) } - /// Returns the total number of hooks across all event types. pub fn len(&self) -> usize { self.hooks.values().map(|v| v.len()).sum() } - /// Append additional hook specs into this registry. pub fn append_specs(&mut self, specs: Vec) { for spec in specs { self.hooks.entry(spec.event).or_default().push(spec); } } - /// Remove all hook specs whose name starts with the given prefix. pub fn remove_by_prefix(&mut self, prefix: &str) { for specs in self.hooks.values_mut() { specs.retain(|s| !s.name.starts_with(prefix)); @@ -66,7 +88,6 @@ impl HookRegistry { HookEventName::SessionEnd, ]; - /// Returns all hooks as a flat list, ordered by event type then position. pub fn all_hooks(&self) -> Vec<&HookSpec> { let mut all = Vec::new(); for event in Self::ALL_EVENTS { @@ -75,21 +96,10 @@ impl HookRegistry { all } - /// Recompile the `matcher` field on every [`HookSpec`] from its - /// `configured_matcher` pattern string. - /// - /// After deserialization the compiled [`HookMatcher`] is `None` - /// (`#[serde(skip)]`). This rebuilds it via [`HookMatcher::new`]. - /// - /// Specs whose `configured_matcher` is `None` (intentional match-all) - /// are left untouched. Invalid patterns cannot be rejected the way the - /// parse path does (`HookError::InvalidMatcher` + skip the hook): the - /// registry is already live, so we install [`HookMatcher::never`] - /// instead: fail closed rather than widening to match all. - /// - /// Call this after any serde / wire restore (e.g. workspace proxy - /// `wire_to_hook_registry`). Until then, a configured pattern with - /// `matcher: None` behaves as match-all. + /// Rebuild the `matcher` field (serde skips it) from `configured_matcher` + /// after any wire restore; until then a configured pattern acts as match-all. + /// An invalid pattern can't be rejected here (the registry is live), so it + /// installs [`HookMatcher::never`]: fail closed rather than match all. pub fn recompile_matchers(&mut self) { for specs in self.hooks.values_mut() { for spec in specs.iter_mut() { @@ -113,11 +123,10 @@ impl HookRegistry { } } -/// A hook source: either a single settings file or a directory of hook files. #[derive(Debug, Clone)] pub enum HookSource<'a> { - /// A single JSON settings file (e.g. `~/.claude/settings.json`). - /// The `hooks` key is extracted; other keys are ignored. + /// A JSON settings file (e.g. `~/.claude/settings.json`); only its `hooks` + /// key is used. SettingsFile(&'a Path), /// A directory of `*.json` hook files (e.g. `~/.grok/hooks/`). Directory(&'a Path), @@ -125,12 +134,8 @@ pub enum HookSource<'a> { /// Load hooks from global and project sources. /// -/// Sources are additive: hooks from all sources are merged into a single -/// registry. Global hooks run before project hooks. Within each scope, -/// earlier sources execute before later sources. -/// -/// Returns the registry plus any non-fatal load errors. -/// A fully empty registry is valid (no-op when no hooks are configured). +/// Sources are additive; global hooks run before project. An empty registry is +/// valid. pub fn load_hooks_from_sources( global_sources: &[HookSource<'_>], project_sources: &[HookSource<'_>], @@ -144,7 +149,6 @@ pub fn load_hooks_from_sources( let mut all_specs = Vec::new(); let mut all_errors = Vec::new(); - // Load global hooks first (precedence order: global, then project). for source in global_sources { let (mut specs, errors) = load_from_source(source); for spec in &mut specs { @@ -159,7 +163,6 @@ pub fn load_hooks_from_sources( all_errors.extend(errors); } - // Load project hooks second. for source in project_sources { let (mut specs, errors) = load_from_source(source); for spec in &mut specs { @@ -174,22 +177,17 @@ pub fn load_hooks_from_sources( all_errors.extend(errors); } - // Index by event type, deduplicating by hook content (command/url) + - // matcher across all sources. This prevents the same hook from executing - // multiple times when it's defined in multiple sources (e.g., ~/.grok/hooks/ + - // ~/.claude/settings.json + ~/.cursor/hooks.json), while still allowing - // hooks that share a command/URL but have different matchers (e.g. tool-scoped - // hooks) to all run. - // - // Deduplication key: (event, command_raw, url_raw, configured_matcher). - // Hooks with identical content + matcher are deduplicated regardless of - // source. Global hooks take precedence because they're loaded first. + // Deduplicate across sources on (canonical event, command_raw, url_raw, + // configured_matcher) so a hook defined in several sources runs once, while + // hooks sharing a command/URL but differing by matcher all still run. The + // canonical event collapses aliases (`SubagentStop`/`SubagentEnd`). Global + // hooks win because they are loaded first. let mut hooks: HashMap> = HashMap::new(); let mut seen_content: std::collections::HashSet<(HookEventName, String, String, String)> = std::collections::HashSet::new(); for spec in all_specs { let key = ( - spec.event, + spec.event.canonical(), spec.command_raw.clone().unwrap_or_default(), spec.url_raw.clone().unwrap_or_default(), spec.configured_matcher.clone().unwrap_or_default(), @@ -236,7 +234,6 @@ pub fn load_hooks( load_hooks_from_sources(&global, &project) } -/// Load hooks from a single source (settings file or directory). fn load_from_source(source: &HookSource<'_>) -> (Vec, Vec) { match source { HookSource::SettingsFile(path) => load_hooks_from_settings_file(path), @@ -244,16 +241,14 @@ fn load_from_source(source: &HookSource<'_>) -> (Vec, Vec) } } -/// Load hooks from a single JSON settings file. -/// -/// Reads the file, extracts the `hooks` key, and parses it. If the file -/// does not exist or has no `hooks` key, returns empty results (not an error). +/// Load hooks from a single JSON settings file. A missing file or absent +/// `hooks` key returns empty results, not an error. fn load_hooks_from_settings_file(path: &Path) -> (Vec, Vec) { let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(e) => { if e.kind() == std::io::ErrorKind::NotFound { - return (Vec::new(), Vec::new()); // Missing file is fine. + return (Vec::new(), Vec::new()); } return ( Vec::new(), @@ -272,11 +267,6 @@ fn load_hooks_from_settings_file(path: &Path) -> (Vec, Vec) (specs, errors) } -/// Load hooks from a single directory. -/// -/// - Only loads `*.json` files. -/// - Ignores hidden/temp/editor files (dotfiles, `~`-suffixed, `.swp`). -/// - Sorts files lexicographically for deterministic ordering. fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { let mut specs = Vec::new(); let mut errors = Vec::new(); @@ -284,7 +274,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { - // Missing directory is not an error — it just means no hooks. if e.kind() == std::io::ErrorKind::NotFound { return (specs, errors); } @@ -296,7 +285,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { } }; - // Collect and sort file paths lexicographically. let mut json_files: Vec = Vec::new(); for entry in entries { let entry = match entry { @@ -318,7 +306,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { } json_files.sort(); - // Parse each file. for path in json_files { let content = match std::fs::read_to_string(&path) { Ok(c) => c, @@ -348,22 +335,15 @@ fn is_valid_hook_file(path: &Path) -> bool { return false; }; - // Must have .json extension. if path.extension().and_then(|e| e.to_str()) != Some("json") { return false; } - - // Skip hidden files (dotfiles). if name.starts_with('.') { return false; } - - // Skip editor temp files. if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") { return false; } - - // Must be a file, not a directory. path.is_file() } @@ -375,14 +355,11 @@ mod tests { std::fs::write(dir.join(name), content).unwrap(); } - /// Create a simple compatible-format JSON hook file for the given event. - /// The `unique_id` parameter ensures each hook has a unique command, - /// preventing deduplication when testing multiple files. fn simple_hook(event: &str) -> String { simple_hook_with_id(event, "test") } - /// Create a simple compatible-format JSON hook file with a unique command. + /// A hook file whose command is keyed by `id`, so distinct ids avoid dedup. fn simple_hook_with_id(event: &str, id: &str) -> String { serde_json::json!({ "hooks": { @@ -392,6 +369,74 @@ mod tests { .to_string() } + /// Drift guard for the hand-maintained `ALL_EVENTS`: a new `HookEventName` + /// variant breaks the exhaustive match below, then fails the assertion until + /// it is added to `ALL_EVENTS`, so no event vanishes from the flat listing. + #[test] + fn all_events_lists_every_variant() { + let every_variant = [ + HookEventName::SessionStart, + HookEventName::UserPromptSubmit, + HookEventName::PreToolUse, + HookEventName::PostToolUse, + HookEventName::PostToolUseFailure, + HookEventName::PermissionDenied, + HookEventName::Stop, + HookEventName::StopFailure, + HookEventName::Notification, + HookEventName::SubagentStart, + HookEventName::SubagentStop, + HookEventName::SubagentEnd, + HookEventName::PreCompact, + HookEventName::PostCompact, + HookEventName::SessionEnd, + ]; + for event in every_variant { + match event { + HookEventName::SessionStart + | HookEventName::UserPromptSubmit + | HookEventName::PreToolUse + | HookEventName::PostToolUse + | HookEventName::PostToolUseFailure + | HookEventName::PermissionDenied + | HookEventName::Stop + | HookEventName::StopFailure + | HookEventName::Notification + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::SubagentEnd + | HookEventName::PreCompact + | HookEventName::PostCompact + | HookEventName::SessionEnd => {} + } + assert!( + HookRegistry::ALL_EVENTS.contains(&event), + "{event} is missing from ALL_EVENTS" + ); + } + } + + /// Drift guard: gate events must match the `blockingEvents` the agent + /// advertises (extensions/hooks.rs). A new gate event fails here. + #[test] + fn gate_events_are_the_known_set() { + use crate::event::GateKind; + // Canonicalize first: `traits()` is unreachable on alias variants. + let gates: std::collections::HashSet<_> = HookRegistry::ALL_EVENTS + .iter() + .map(|e| e.canonical()) + .filter(|e| e.traits().gate != GateKind::Observe) + .collect(); + let expected: std::collections::HashSet<_> = [ + HookEventName::PreToolUse, + HookEventName::Stop, + HookEventName::SubagentStop, + ] + .into_iter() + .collect(); + assert_eq!(gates, expected, "gate events changed"); + } + #[test] fn load_empty_dirs() { let dir = tempfile::tempdir().unwrap(); @@ -401,13 +446,6 @@ mod tests { assert_eq!(registry.len(), 0); } - #[test] - fn load_missing_dirs() { - let (registry, errors) = load_hooks(None, None); - assert!(errors.is_empty()); - assert!(registry.is_empty()); - } - #[test] fn load_nonexistent_dir() { let (registry, errors) = load_hooks(Some(Path::new("/nonexistent/path/hooks")), None); @@ -430,7 +468,6 @@ mod tests { #[test] fn lexicographic_ordering_across_files() { let dir = tempfile::tempdir().unwrap(); - // Use unique IDs so hooks aren't deduplicated. write_json( dir.path(), "02-second.json", @@ -450,15 +487,18 @@ mod tests { let (registry, errors) = load_hooks(Some(dir.path()), None); assert!(errors.is_empty()); let hooks = registry.hooks_for(HookEventName::PreToolUse); - assert_eq!(hooks.len(), 3); - // All hooks are PreToolUse, loaded in file order (01, 02, 03). + let commands: Vec<_> = hooks.iter().map(|h| h.command_raw.as_deref()).collect(); + assert_eq!( + commands, + [Some("first.sh"), Some("second.sh"), Some("third.sh")], + "hooks must load in lexicographic file order (01-, 02-, 03-)" + ); } #[test] fn global_before_project() { let global = tempfile::tempdir().unwrap(); let project = tempfile::tempdir().unwrap(); - // Use unique IDs so hooks aren't deduplicated. write_json( global.path(), "global.json", @@ -527,32 +567,9 @@ mod tests { assert_eq!(registry.len(), 2); } - #[test] - fn hooks_indexed_by_event_type() { - let dir = tempfile::tempdir().unwrap(); - // One file with all four event types. - let content = r#"{ - "hooks": { - "SessionStart": [{"hooks": [{"type": "command", "command": "a.sh"}]}], - "PreToolUse": [{"hooks": [{"type": "command", "command": "b.sh"}]}], - "PostToolUse": [{"hooks": [{"type": "command", "command": "c.sh"}]}], - "SessionEnd": [{"hooks": [{"type": "command", "command": "d.sh"}]}] - } - }"#; - write_json(dir.path(), "all.json", content); - - let (registry, errors) = load_hooks(Some(dir.path()), None); - assert!(errors.is_empty()); - assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1); - assert_eq!(registry.hooks_for(HookEventName::PreToolUse).len(), 1); - assert_eq!(registry.hooks_for(HookEventName::PostToolUse).len(), 1); - assert_eq!(registry.hooks_for(HookEventName::SessionEnd).len(), 1); - } - #[test] fn all_hooks_covers_every_event_type() { let dir = tempfile::tempdir().unwrap(); - // Create hooks for all 10 event types in one file. let content = r#"{ "hooks": { "SessionStart": [{"hooks": [{"type": "command", "command": "a.sh"}]}], @@ -573,27 +590,13 @@ mod tests { assert!(errors.is_empty(), "errors: {errors:?}"); assert_eq!(registry.len(), 10); - // all_hooks() must return all 10 — not just the original 4. let all = registry.all_hooks(); + let events: std::collections::HashSet<_> = all.iter().map(|h| h.event).collect(); assert_eq!( - all.len(), + events.len(), 10, - "all_hooks() returned {} hooks, expected 10 (all event types)", - all.len() + "all_hooks() must cover 10 distinct event types" ); - - // Verify each event type is represented. - let events: Vec = all.iter().map(|h| h.event).collect(); - assert!(events.contains(&HookEventName::SessionStart)); - assert!(events.contains(&HookEventName::PreToolUse)); - assert!(events.contains(&HookEventName::PostToolUse)); - assert!(events.contains(&HookEventName::SessionEnd)); - assert!(events.contains(&HookEventName::Stop)); - assert!(events.contains(&HookEventName::Notification)); - assert!(events.contains(&HookEventName::UserPromptSubmit)); - assert!(events.contains(&HookEventName::SubagentStart)); - assert!(events.contains(&HookEventName::SubagentStop)); - assert!(events.contains(&HookEventName::SubagentEnd)); } #[test] @@ -621,8 +624,6 @@ mod tests { assert!(!is_valid_hook_file(&toml)); // TOML no longer accepted } - // ── Settings file discovery tests ──────────────────────────── - #[test] fn load_from_settings_file() { let dir = tempfile::tempdir().unwrap(); @@ -667,7 +668,6 @@ mod tests { fn mixed_sources_settings_and_directory() { let dir = tempfile::tempdir().unwrap(); - // Settings file with one hook. let settings = dir.path().join("settings.json"); std::fs::write( &settings, @@ -675,7 +675,6 @@ mod tests { ) .unwrap(); - // Directory with another hook. let hooks_dir = dir.path().join("hooks"); std::fs::create_dir_all(&hooks_dir).unwrap(); write_json(&hooks_dir, "extra.json", &simple_hook("SessionStart")); @@ -688,7 +687,6 @@ mod tests { &[], ); assert!(errors.is_empty(), "errors: {errors:?}"); - // Both hooks should be loaded (additive merge). assert_eq!(registry.len(), 2); assert_eq!(registry.hooks_for(HookEventName::PreToolUse).len(), 1); assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1); @@ -719,7 +717,6 @@ mod tests { assert!(errors.is_empty()); let hooks = registry.hooks_for(HookEventName::PreToolUse); assert_eq!(hooks.len(), 2); - // Global hook first, project hook second. assert!(hooks[0].name.starts_with("global/")); assert!(hooks[1].name.starts_with("project/")); } @@ -728,8 +725,6 @@ mod tests { fn deduplicates_hooks_with_same_content_across_sources() { let dir = tempfile::tempdir().unwrap(); - // Create three sources with the SAME hook command. - // Only the first one (global) should be kept. let global_settings = dir.path().join("global.json"); std::fs::write( &global_settings, @@ -760,7 +755,6 @@ mod tests { &[], ); assert!(errors.is_empty()); - // Only one hook should be loaded (the first one, from global). let hooks = registry.hooks_for(HookEventName::SessionStart); assert_eq!( hooks.len(), @@ -775,11 +769,37 @@ mod tests { ); } + /// A hook registered under both `SubagentStop` and `SubagentEnd` dedups on + /// the canonical event, so it runs once. + #[test] + fn deduplicates_hooks_across_alias_spellings() { + let dir = tempfile::tempdir().unwrap(); + let settings = dir.path().join("settings.json"); + std::fs::write( + &settings, + r#"{"hooks":{ + "SubagentStop":[{"hooks":[{"type":"command","command":"notify.sh"}]}], + "SubagentEnd":[{"hooks":[{"type":"command","command":"notify.sh"}]}] + }}"#, + ) + .unwrap(); + + let (registry, errors) = + load_hooks_from_sources(&[HookSource::SettingsFile(&settings)], &[]); + assert!(errors.is_empty()); + assert_eq!( + registry + .hooks_for_canonical(HookEventName::SubagentStop) + .len(), + 1, + "alias spelling must not double-register the same hook" + ); + } + #[test] fn different_commands_not_deduplicated() { let dir = tempfile::tempdir().unwrap(); - // Different hook commands - should NOT be deduplicated. let global_settings = dir.path().join("global.json"); std::fs::write( &global_settings, @@ -802,7 +822,6 @@ mod tests { &[], ); assert!(errors.is_empty()); - // Both hooks should be loaded since they have different commands. let hooks = registry.hooks_for(HookEventName::SessionStart); assert_eq!( hooks.len(), @@ -816,7 +835,6 @@ mod tests { fn different_event_types_not_deduplicated() { let dir = tempfile::tempdir().unwrap(); - // Same command but different event types - should NOT be deduplicated. let settings = dir.path().join("settings.json"); std::fs::write( &settings, @@ -832,19 +850,16 @@ mod tests { let (registry, errors) = load_hooks_from_sources(&[HookSource::SettingsFile(&settings)], &[]); assert!(errors.is_empty()); - // Both hooks should be loaded since they're different event types. assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1); assert_eq!(registry.hooks_for(HookEventName::SessionEnd).len(), 1); } + /// The same command in multiple files within one directory dedups to a + /// single run, preventing accidental duplicate execution. #[test] fn same_command_in_same_directory_deduplicated() { - // When the same hook command is defined in multiple files within - // the same directory, they should be deduplicated (only the first - // one runs). This prevents accidental duplicate execution. let dir = tempfile::tempdir().unwrap(); - // Two files with the same hook command. write_json( dir.path(), "01-first.json", @@ -858,7 +873,6 @@ mod tests { let (registry, errors) = load_hooks(Some(dir.path()), None); assert!(errors.is_empty()); - // Only one hook should be loaded (deduplicated by content). let hooks = registry.hooks_for(HookEventName::SessionStart); assert_eq!( hooks.len(), @@ -872,7 +886,6 @@ mod tests { fn realistic_claude_settings_discovery() { let dir = tempfile::tempdir().unwrap(); - // Simulate ~/.claude/settings.json with many extra keys. let claude_settings = dir.path().join("settings.json"); std::fs::write( &claude_settings, @@ -904,7 +917,7 @@ mod tests { crate::config::HookSpec { name: name.into(), event: HookEventName::PreToolUse, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: configured_matcher.map(str::to_owned), matcher: None, enabled: true, @@ -918,38 +931,6 @@ mod tests { } } - #[test] - fn recompile_matchers_fail_closed_on_invalid_pattern() { - // Serde skips `matcher`; recompile must not leave it None (match-all). - let mut registry = HookRegistry::default(); - registry.append_specs(vec![recompile_test_spec("broken", Some("[invalid"))]); - registry.recompile_matchers(); - - let hooks = registry.hooks_for(HookEventName::PreToolUse); - assert_eq!(hooks.len(), 1); - let matcher = hooks[0] - .matcher - .as_ref() - .expect("invalid matcher must compile to never-match, not stay None"); - assert!(!matcher.is_match("run_terminal_command")); - assert!(!matcher.is_match("read_file")); - assert!(!matcher.is_match("Bash")); - } - - #[test] - fn recompile_matchers_restores_valid_pattern() { - let mut registry = HookRegistry::default(); - registry.append_specs(vec![recompile_test_spec("ok", Some("Bash"))]); - registry.recompile_matchers(); - - let matcher = registry.hooks_for(HookEventName::PreToolUse)[0] - .matcher - .as_ref() - .expect("valid matcher should recompile"); - assert!(matcher.is_match("run_terminal_command")); - assert!(!matcher.is_match("read_file")); - } - #[test] fn recompile_matchers_leaves_intentional_match_all() { let mut registry = HookRegistry::default(); diff --git a/crates/codegen/xai-grok-hooks/src/dispatcher.rs b/crates/codegen/xai-grok-hooks/src/dispatcher.rs index 7dd7bc3..d396b6c 100644 --- a/crates/codegen/xai-grok-hooks/src/dispatcher.rs +++ b/crates/codegen/xai-grok-hooks/src/dispatcher.rs @@ -1,14 +1,43 @@ +use crate::config::HookSpec; use crate::discovery::HookRegistry; use crate::event::{HookEventEnvelope, HookEventName}; use crate::result::{HookDecision, HookRunResult}; -use crate::runner::{self, HookRunnerResult, RunContext}; +use crate::runner::{self, GateKind, HookRunnerResult, RunContext}; + +fn dispatch_span(event: HookEventName, hook_count: usize) -> tracing::Span { + tracing::info_span!( + "hooks.dispatch", + hook_event = %event, + hook_count = hook_count as i64, + num_success = tracing::field::Empty, + num_failed = tracing::field::Empty, + num_blocking = tracing::field::Empty, + num_skipped = tracing::field::Empty, + total_duration_ms = tracing::field::Empty, + ) +} + +/// Disabled/trust-disabled specs record a `Skipped` result; a matcher miss +/// records nothing. +fn eligible_or_record_skip( + spec: &HookSpec, + match_value: Option<&str>, + results: &mut Vec, +) -> bool { + if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) { + tracing::info!(hook_name = %spec.name, "hook skipped (disabled)"); + results.push(HookRunResult::Skipped { + hook_name: spec.name.clone(), + }); + return false; + } + crate::matcher::matcher_allows(spec.matcher.as_ref(), match_value) +} /// Result of a `pre_tool_use` dispatch: the final decision plus per-hook /// execution details (for scrollback enrichment). pub struct PreToolUseResult { - /// Final blocking decision (Allow or Deny). pub decision: HookDecision, - /// Per-hook run results (includes HTTP info when applicable). pub results: Vec, } @@ -41,35 +70,14 @@ pub async fn dispatch_pre_tool_use( }; } - let span = tracing::info_span!( - "hooks.dispatch", - hook_event = %HookEventName::PreToolUse, - hook_count = hooks.len() as i64, - num_success = tracing::field::Empty, - num_failed = tracing::field::Empty, - num_blocking = tracing::field::Empty, - num_skipped = tracing::field::Empty, - total_duration_ms = tracing::field::Empty, - ); + let span = dispatch_span(HookEventName::PreToolUse, hooks.len()); let _enter = span.enter(); - let tool_name = extract_tool_name(envelope); + let match_value = envelope.payload.match_value().map(str::to_string); let mut run_results = Vec::new(); for spec in hooks { - if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) { - tracing::info!(hook_name = %spec.name, "hook skipped (disabled)"); - run_results.push(HookRunResult::Skipped { - hook_name: spec.name.clone(), - }); - continue; - } - - // Check matcher against tool name. - if let Some(ref matcher) = spec.matcher - && let Some(ref name) = tool_name - && !matcher.is_match(name) - { + if !eligible_or_record_skip(spec, match_value.as_deref(), &mut run_results) { continue; } @@ -80,7 +88,8 @@ pub async fn dispatch_pre_tool_use( ) .entered(); - let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, true).await; + let (result, elapsed, http_info) = + runner::run_hook(spec, envelope, ctx, GateKind::Tool).await; match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { @@ -90,13 +99,13 @@ pub async fn dispatch_pre_tool_use( reason = %reason, "hook denied" ); - run_results.push(HookRunResult::Failed { + run_results.push(HookRunResult::Blocked { hook_name: spec.name.clone(), - error: format!("denied: {reason}"), + detail: format!("denied: {reason}"), elapsed, http_info, }); - record_dispatch_counts(&span, &run_results, 1); + record_dispatch_counts(&span, &run_results); return PreToolUseResult { decision: HookDecision::Deny { reason, @@ -117,11 +126,6 @@ pub async fn dispatch_pre_tool_use( http_info, }); } - // Fail-open: hook failures (timeouts, crashes, refusals to - // spawn, malformed output) are logged and recorded for the UI - // but do not deny the tool call. Only an explicit `deny` - // decision blocks. See module docs on dispatch_pre_tool_use - // for the rationale (protected-environment threat model). HookRunnerResult::Failed(err) => { tracing::warn!( hook_name = %spec.name, @@ -136,8 +140,7 @@ pub async fn dispatch_pre_tool_use( http_info, }); } - HookRunnerResult::Success => { - // Shouldn't happen for blocking hooks, but treat as allow. + HookRunnerResult::Success | HookRunnerResult::Stop(_) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, @@ -152,58 +155,125 @@ pub async fn dispatch_pre_tool_use( } } - record_dispatch_counts(&span, &run_results, 0); + record_dispatch_counts(&span, &run_results); PreToolUseResult { decision: HookDecision::Allow, results: run_results, } } -/// Dispatch a non-blocking event (`session_start`, `post_tool_use`, `session_end`) -/// against all matching hooks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StopBlock { + pub hook_name: String, + pub reason: String, +} + +/// Aggregated signals from a `Stop`/`SubagentStop` gate dispatch. +#[derive(Debug, Default)] +pub struct StopDispatchResult { + pub blocks: Vec, + pub additional_context: Vec, + /// First `continue: false` wins and overrides any blocks. + pub prevent_continuation: Option, + pub results: Vec, +} + +impl StopDispatchResult { + pub fn wants_continuation(&self) -> bool { + self.prevent_continuation.is_none() + && (!self.blocks.is_empty() || !self.additional_context.is_empty()) + } + + /// The first force-stop wins (later ones are dropped); blocks and context + /// accumulate in call order. + pub fn absorb(&mut self, hook_name: &str, signals: StopSignals) { + if let Some(reason) = signals.stop_reason + && self.prevent_continuation.is_none() + { + self.prevent_continuation = Some(StopBlock { + hook_name: hook_name.to_string(), + reason, + }); + } + if let Some(reason) = signals.block_reason { + self.blocks.push(StopBlock { + hook_name: hook_name.to_string(), + reason, + }); + } + if let Some(context) = signals.additional_context { + self.additional_context.push(context); + } + } +} + +/// One hook's stop signals, normalized for [`StopDispatchResult::absorb`]. +/// A `Some` in `stop_reason` is what marks the hook as force-stopping. +#[derive(Debug, Default)] +pub struct StopSignals { + pub block_reason: Option, + pub stop_reason: Option, + pub additional_context: Option, +} + +/// Scrollback detail for a stop signal, shared by the file and client gates so +/// the wording can't drift. A force-stop wins over a block; its reason may be absent. +pub fn stop_detail( + prevented: bool, + prevent_reason: Option<&str>, + block_reason: Option<&str>, +) -> Option { + if prevented { + return Some(match prevent_reason { + Some(reason) => format!("prevented continuation: {reason}"), + None => "prevented continuation".to_string(), + }); + } + block_reason.map(|reason| format!("blocked stop: {reason}")) +} + +fn stop_outcome_detail(outcome: &crate::result::StopHookOutcome) -> Option { + stop_detail( + outcome.force_stop.is_some(), + outcome + .force_stop + .as_ref() + .and_then(|f| f.reason.as_deref()), + outcome.block_reason.as_deref(), + ) +} + +/// Dispatch a `Stop` or `SubagentStop` gate against all matching hooks. /// -/// Runs hooks sequentially, collects results. Never denies — callers log -/// results and continue. -pub async fn dispatch_non_blocking( +/// Every hook runs (no short-circuit) so the model sees all block reasons and +/// additional context at once. Hook failures (timeouts, crashes, malformed +/// output) are fail-open: recorded for the UI but contribute no signal, so the +/// agent stops normally. +pub async fn dispatch_stop( registry: &HookRegistry, event: HookEventName, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, -) -> Vec { - let hooks = registry.hooks_for(event); +) -> StopDispatchResult { + if event.traits().gate != GateKind::Stop { + debug_assert!(false, "dispatch_stop called with non-stop event {event:?}"); + tracing::error!(%event, "dispatch_stop called with a non-stop event; ignoring"); + return StopDispatchResult::default(); + } + let event = event.canonical(); + let hooks = registry.hooks_for_canonical(event); if hooks.is_empty() { - return Vec::new(); + return StopDispatchResult::default(); } - let span = tracing::info_span!( - "hooks.dispatch", - hook_event = %event, - hook_count = hooks.len() as i64, - num_success = tracing::field::Empty, - num_failed = tracing::field::Empty, - num_blocking = tracing::field::Empty, - num_skipped = tracing::field::Empty, - total_duration_ms = tracing::field::Empty, - ); + let span = dispatch_span(event, hooks.len()); let _enter = span.enter(); - let tool_name = extract_tool_name(envelope); - let mut results = Vec::with_capacity(hooks.len()); + let mut out = StopDispatchResult::default(); + let match_value = envelope.payload.match_value().map(str::to_string); for spec in hooks { - if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) { - tracing::info!(hook_name = %spec.name, "hook skipped (disabled)"); - results.push(HookRunResult::Skipped { - hook_name: spec.name.clone(), - }); - continue; - } - - // Check matcher against tool name (only for tool events). - if let Some(ref matcher) = spec.matcher - && let Some(ref name) = tool_name - && !matcher.is_match(name) - { + if !eligible_or_record_skip(spec, match_value.as_deref(), &mut out.results) { continue; } @@ -214,7 +284,111 @@ pub async fn dispatch_non_blocking( ) .entered(); - let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, false).await; + let (result, elapsed, http_info) = + runner::run_hook(spec, envelope, ctx, GateKind::Stop).await; + + match result { + HookRunnerResult::Stop(outcome) => { + tracing::info!( + hook_name = %spec.name, + elapsed_ms = elapsed.as_millis() as u64, + block = outcome.block_reason.is_some(), + additional_context = outcome.additional_context.is_some(), + prevent_continuation = outcome.force_stop.is_some(), + "stop hook completed" + ); + match stop_outcome_detail(&outcome) { + Some(detail) => { + out.results.push(HookRunResult::Blocked { + hook_name: spec.name.clone(), + detail, + elapsed, + http_info, + }); + } + None => out.results.push(HookRunResult::Success { + hook_name: spec.name.clone(), + elapsed, + http_info, + }), + } + out.absorb( + &spec.name, + StopSignals { + block_reason: outcome.block_reason, + stop_reason: outcome.force_stop.map(|force| { + force + .reason + .unwrap_or_else(|| "stopped by hook".to_string()) + }), + additional_context: outcome.additional_context, + }, + ); + } + HookRunnerResult::Failed(err) => { + tracing::warn!( + hook_name = %spec.name, + elapsed_ms = elapsed.as_millis() as u64, + error = %err, + "stop hook failed; ignoring (fail-open)" + ); + out.results.push(HookRunResult::Failed { + hook_name: spec.name.clone(), + error: err, + elapsed, + http_info, + }); + } + HookRunnerResult::Success | HookRunnerResult::Decision(_) => { + out.results.push(HookRunResult::Success { + hook_name: spec.name.clone(), + elapsed, + http_info, + }); + } + } + } + + record_dispatch_counts(&span, &out.results); + out +} + +/// Dispatch an observe-only event against all matching hooks; never denies. +pub async fn dispatch_non_blocking( + registry: &HookRegistry, + event: HookEventName, + envelope: &HookEventEnvelope, + ctx: &RunContext<'_>, +) -> Vec { + debug_assert!( + event.traits().gate == GateKind::Observe, + "dispatch_non_blocking called with gate event {event:?}" + ); + let hooks = registry.hooks_for_canonical(event); + if hooks.is_empty() { + return Vec::new(); + } + + let span = dispatch_span(event, hooks.len()); + let _enter = span.enter(); + + let match_value = envelope.payload.match_value().map(str::to_string); + let mut results = Vec::with_capacity(hooks.len()); + + for spec in hooks { + if !eligible_or_record_skip(spec, match_value.as_deref(), &mut results) { + continue; + } + + let _hook_span = tracing::info_span!( + "hook.run", + hook_name = %spec.name, + hook_event = %event, + ) + .entered(); + + let (result, elapsed, http_info) = + runner::run_hook(spec, envelope, ctx, GateKind::Observe).await; match result { HookRunnerResult::Success => { @@ -243,8 +417,7 @@ pub async fn dispatch_non_blocking( http_info, }); } - HookRunnerResult::Decision(_) => { - // Shouldn't happen for non-blocking hooks. + HookRunnerResult::Decision(_) | HookRunnerResult::Stop(_) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, @@ -259,25 +432,27 @@ pub async fn dispatch_non_blocking( } } - record_dispatch_counts(&span, &results, 0); + record_dispatch_counts(&span, &results); results } -/// Record hook outcome counts on the `hooks.dispatch` span. A blocking deny is -/// stored as a `Failed` result, so `num_blocking` is passed in and subtracted -/// from `num_failed` to avoid double-counting. -fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult], num_blocking: i64) { +fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult]) { let mut num_success = 0i64; let mut num_failed = 0i64; let mut num_skipped = 0i64; let mut total_duration_ms = 0i64; + let mut num_blocked = 0i64; for r in results { match r { HookRunResult::Success { elapsed, .. } => { num_success += 1; total_duration_ms += elapsed.as_millis() as i64; } + HookRunResult::Blocked { elapsed, .. } => { + num_blocked += 1; + total_duration_ms += elapsed.as_millis() as i64; + } HookRunResult::Failed { elapsed, .. } => { num_failed += 1; total_duration_ms += elapsed.as_millis() as i64; @@ -286,41 +461,16 @@ fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult], num_b } } span.record("num_success", num_success); - span.record("num_failed", num_failed - num_blocking); - span.record("num_blocking", num_blocking); + span.record("num_failed", num_failed); + span.record("num_blocking", num_blocked); span.record("num_skipped", num_skipped); span.record("total_duration_ms", total_duration_ms); } -/// Build the hub custom hook `kind` string for a non-blocking hook event. -/// -/// Returns `None` for `PreToolUse` (blocking, local-only). For all other -/// events the kind is `"hook."`, derived from the -/// `Display` impl of `HookEventName`. +/// `"hook."` for hub-forwarded events, or `None` for +/// local-only events (`PreToolUse`). pub fn hub_hook_kind(event: HookEventName) -> Option { - if event.is_blocking() { - return None; - } - Some(format!("hook.{event}")) -} - -/// The tool name a matcher is tested against, or `None` for events with no tool -/// (lifecycle, prompt, compaction). `Notification` matches on its `notification_type`. -/// -/// `tool_name` is the resolved underlying tool for meta-dispatch tools (`use_tool` -/// and the external MCP-call tool), so a matcher keyed on the real tool fires directly. -pub fn extract_tool_name(envelope: &HookEventEnvelope) -> Option { - use crate::event::HookPayload; - match &envelope.payload { - HookPayload::PreToolUse { tool_name, .. } => Some(tool_name.clone()), - HookPayload::PostToolUse { tool_name, .. } => Some(tool_name.clone()), - HookPayload::PostToolUseFailure { tool_name, .. } => Some(tool_name.clone()), - HookPayload::PermissionDenied { tool_name, .. } => Some(tool_name.clone()), - HookPayload::Notification { - notification_type, .. - } => Some(notification_type.clone()), - _ => None, - } + event.traits().hub_forward.then(|| format!("hook.{event}")) } #[cfg(test)] @@ -332,7 +482,6 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - /// Helper: build a pre_tool_use envelope for the given tool name. fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, @@ -343,18 +492,17 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::PreToolUse { tool_name: tool_name.into(), tool_use_id: "tu-1".into(), tool_input: serde_json::json!({"command": "ls"}), tool_input_truncated: false, - permission_mode: None, subagent_type: None, }, } } - /// Helper: build a session_start envelope. fn session_start_envelope() -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::SessionStart, @@ -365,6 +513,7 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::SessionStart { source: "new".into(), model_id: None, @@ -391,7 +540,7 @@ mod tests { HookSpec { name: name.into(), event: HookEventName::PreToolUse, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: matcher.map(|s| s.to_string()), matcher: matcher.map(|s| HookMatcher::new(s).unwrap()), enabled, @@ -405,55 +554,49 @@ mod tests { } } - /// Build a registry from a list of specs using the public API. fn registry_from_specs(specs: Vec) -> HookRegistry { let (mut registry, _) = crate::discovery::load_hooks(None, None); registry.append_specs(specs); registry } - // ── extract_tool_name tests ────────────────────────────────── - #[test] - fn extract_tool_name_from_pre_tool_use() { - let envelope = pre_tool_use_envelope("run_terminal_cmd"); + fn match_value_extracts_per_payload_field() { assert_eq!( - extract_tool_name(&envelope), - Some("run_terminal_cmd".into()) + pre_tool_use_envelope("run_terminal_cmd") + .payload + .match_value(), + Some("run_terminal_cmd") ); - } + assert_eq!(session_start_envelope().payload.match_value(), Some("new")); - #[test] - fn extract_tool_name_from_session_start_is_none() { - let envelope = session_start_envelope(); - assert_eq!(extract_tool_name(&envelope), None); - } - - #[test] - fn extract_tool_name_from_notification() { - let envelope = HookEventEnvelope { - hook_event_name: HookEventName::Notification, - session_id: "s".into(), - cwd: "/tmp".into(), - workspace_root: "/tmp".into(), - timestamp: "t".into(), - transcript_path: None, - client_identifier: None, - prompt_id: None, - payload: HookPayload::Notification { - notification_type: "permission_prompt".into(), - message: None, - title: None, - level: None, - }, + let notification = HookPayload::Notification { + notification_type: "permission_prompt".into(), + message: None, + title: None, + level: None, }; - assert_eq!( - extract_tool_name(&envelope), - Some("permission_prompt".into()) - ); + assert_eq!(notification.match_value(), Some("permission_prompt")); } - // ── dispatch_pre_tool_use tests ────────────────────────────── + /// An empty subagent type (parent-side fire with no spawn record) yields + /// `None` so matchers fire-all instead of silently matching nothing. + #[test] + fn subagent_match_value_is_none_when_type_empty() { + let mut envelope = stop_envelope(); + envelope.hook_event_name = HookEventName::SubagentStop; + let payload = |subagent_type: &str| HookPayload::SubagentStop { + phase: crate::event::SubagentStopPhase::Observe, + subagent_id: "sub-1".into(), + subagent_type: subagent_type.into(), + stop_hook_active: None, + last_assistant_message: None, + }; + envelope.payload = payload("explore"); + assert_eq!(envelope.payload.match_value(), Some("explore")); + envelope.payload = payload(""); + assert_eq!(envelope.payload.match_value(), None); + } #[tokio::test] async fn empty_registry_allows() { @@ -463,15 +606,6 @@ mod tests { assert_eq!(result.decision, HookDecision::Allow); } - #[tokio::test] - async fn single_allow_hook() { - let spec = make_command_spec("allow-hook", None, true, "echo '{\"decision\":\"allow\"}'"); - let registry = registry_from_specs(vec![spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!(result.decision, HookDecision::Allow); - } - #[tokio::test] async fn single_deny_hook() { let spec = make_command_spec( @@ -497,7 +631,6 @@ mod tests { #[tokio::test] async fn disabled_hook_is_skipped_allows() { - // A deny hook that is disabled should be skipped entirely. let spec = make_command_spec( "disabled-deny", None, @@ -511,23 +644,7 @@ mod tests { } #[tokio::test] - async fn matcher_skips_non_matching_tool() { - // Deny hook with matcher for "read_file" should not fire for "run_terminal_cmd". - let spec = make_command_spec( - "read-only-deny", - Some("read_file"), - true, - "echo '{\"decision\":\"deny\",\"reason\":\"blocked\"}'; exit 2", - ); - let registry = registry_from_specs(vec![spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!(result.decision, HookDecision::Allow); - } - - #[tokio::test] - async fn matcher_fires_on_matching_tool() { - // Deny hook with matcher for "run_terminal_cmd" should fire. + async fn matcher_filters_by_tool() { let spec = make_command_spec( "bash-deny", Some("run_terminal_cmd"), @@ -535,17 +652,25 @@ mod tests { "echo '{\"decision\":\"deny\",\"reason\":\"bash blocked\"}'; exit 2", ); let registry = registry_from_specs(vec![spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - match result.decision { + + let fired = dispatch_pre_tool_use( + ®istry, + &pre_tool_use_envelope("run_terminal_cmd"), + &run_ctx(), + ) + .await; + match fired.decision { HookDecision::Deny { ref reason, .. } => assert_eq!(reason, "bash blocked"), ref other => panic!("expected Deny, got {other:?}"), } + + let skipped = + dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &run_ctx()).await; + assert_eq!(skipped.decision, HookDecision::Allow); } #[tokio::test] async fn first_deny_wins_short_circuits() { - // Two hooks: first denies, second allows. First deny should win. let deny_spec = make_command_spec( "first-deny", None, @@ -576,8 +701,6 @@ mod tests { #[tokio::test] async fn allow_then_deny_denies() { - // First hook allows, second hook denies. The deny should win. - // This is the key "stricter deny filter takes precedence" scenario. let allow_spec = make_command_spec("broad-allow", None, true, "echo '{\"decision\":\"allow\"}'"); let deny_spec = make_command_spec( @@ -602,49 +725,8 @@ mod tests { } } - #[tokio::test] - async fn allow_broad_deny_specific_tool_match() { - // Broad allow hook (no matcher), specific deny hook for "run_terminal_cmd". - // The deny should fire for matching tool even though allow came first. - let allow_spec = - make_command_spec("allow-all", None, true, "echo '{\"decision\":\"allow\"}'"); - let deny_spec = make_command_spec( - "deny-bash", - Some("run_terminal_cmd"), - true, - "echo '{\"decision\":\"deny\",\"reason\":\"bash not allowed\"}'; exit 2", - ); - let registry = registry_from_specs(vec![allow_spec, deny_spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - match result.decision { - HookDecision::Deny { ref reason, .. } => assert_eq!(reason, "bash not allowed"), - ref other => panic!("expected Deny, got {other:?}"), - } - } - - #[tokio::test] - async fn allow_broad_deny_specific_non_matching_allows() { - // Broad allow hook, specific deny for "read_file" only. - // Calling with "run_terminal_cmd" should allow (deny doesn't match). - let allow_spec = - make_command_spec("allow-all", None, true, "echo '{\"decision\":\"allow\"}'"); - let deny_spec = make_command_spec( - "deny-read", - Some("read_file"), - true, - "echo '{\"decision\":\"deny\",\"reason\":\"no read\"}'; exit 2", - ); - let registry = registry_from_specs(vec![allow_spec, deny_spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!(result.decision, HookDecision::Allow); - } - #[tokio::test] async fn fail_open_on_hook_crash() { - // Hook exits with code 1 (crash). Under fail-open the tool call - // should still be allowed; the failure is recorded for the UI. let spec = make_command_spec("crasher", None, true, "exit 1"); let registry = registry_from_specs(vec![spec]); let envelope = pre_tool_use_envelope("run_terminal_cmd"); @@ -664,9 +746,6 @@ mod tests { #[tokio::test] async fn fail_open_then_deny_lets_deny_win() { - // First hook crashes (now fail-open), second denies. Under - // fail-open the chain continues past the crash and the second - // hook's explicit deny is what blocks the call. let crash_spec = make_command_spec("crasher", None, true, "exit 1"); let deny_spec = make_command_spec( "denier", @@ -687,77 +766,286 @@ mod tests { } ref other => panic!("expected Deny from explicit denier, got {other:?}"), } - // Both hooks ran: the crasher recorded a Failed result, the - // denier recorded a Failed result with "denied: nope" prefix. assert_eq!(result.results.len(), 2); + assert!( + matches!(&result.results[1], HookRunResult::Blocked { detail, .. } + if detail == "denied: nope"), + "a deny is the hook's decision, not a failure: {:?}", + result.results[1] + ); } - #[tokio::test] - async fn all_hooks_allow_results_in_allow() { - let specs = vec![ - make_command_spec("a1", None, true, "echo '{\"decision\":\"allow\"}'"), - make_command_spec("a2", None, true, "echo '{\"decision\":\"allow\"}'"), - make_command_spec("a3", None, true, "echo '{\"decision\":\"allow\"}'"), - ]; - let registry = registry_from_specs(specs); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!(result.decision, HookDecision::Allow); - } - - #[tokio::test] - async fn mixed_disabled_and_deny() { - // Disabled deny hook followed by enabled allow. Should allow. - let disabled_deny = make_command_spec( - "disabled-deny", - None, - false, - "echo '{\"decision\":\"deny\",\"reason\":\"should not run\"}'; exit 2", - ); - let enabled_allow = make_command_spec( - "enabled-allow", - None, - true, - "echo '{\"decision\":\"allow\"}'", - ); - let registry = registry_from_specs(vec![disabled_deny, enabled_allow]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!(result.decision, HookDecision::Allow); - } - - // ── fail-open regression tests ─────────────────────────────── - - #[tokio::test] - async fn fail_open_records_error_in_run_results() { - // A hook that returns malformed output and exits non-zero now - // results in Allow (fail-open) but the failure detail is still - // captured in run_results for the UI scrollback. - let spec = make_command_spec("bad-output", None, true, "echo 'not json'; exit 1"); - let registry = registry_from_specs(vec![spec]); - let envelope = pre_tool_use_envelope("run_terminal_cmd"); - let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await; - assert_eq!( - result.decision, - HookDecision::Allow, - "fail-open: bad output must not block the tool call" - ); - assert_eq!(result.results.len(), 1); - match &result.results[0] { - HookRunResult::Failed { - hook_name, error, .. - } => { - assert_eq!(hook_name, "bad-output"); - assert!( - error.contains("bad-output") || error.contains("exit code"), - "error detail should be preserved for UI: {error}" - ); - } - other => panic!("expected Failed run result, got {other:?}"), + fn stop_envelope() -> HookEventEnvelope { + HookEventEnvelope { + hook_event_name: HookEventName::Stop, + session_id: "test-session".into(), + cwd: "/tmp".into(), + workspace_root: "/tmp".into(), + timestamp: "2025-01-01T00:00:00Z".into(), + transcript_path: None, + client_identifier: None, + prompt_id: None, + permission_mode: None, + payload: HookPayload::Stop { + reason: "end_turn".into(), + stop_hook_active: false, + last_assistant_message: Some("done".into()), + background_tasks: None, + session_crons: None, + }, } } - // ── dispatch_non_blocking tests ────────────────────────────── + fn stop_spec(name: &str, script: &str) -> HookSpec { + let mut spec = make_command_spec(name, None, true, script); + spec.event = HookEventName::Stop; + spec + } + + #[test] + fn absorb_folds_signals_with_first_force_stop_winning() { + let mut out = StopDispatchResult::default(); + out.absorb( + "b1", + StopSignals { + block_reason: Some("first block".into()), + ..Default::default() + }, + ); + out.absorb( + "s1", + StopSignals { + stop_reason: Some("stop now".into()), + additional_context: Some("ctx".into()), + ..Default::default() + }, + ); + out.absorb( + "s2", + StopSignals { + stop_reason: Some("too late".into()), + block_reason: Some("second block".into()), + ..Default::default() + }, + ); + + assert!(!out.wants_continuation(), "a force-stop overrides blocks"); + assert_eq!( + out.blocks + .iter() + .map(|b| b.reason.as_str()) + .collect::>(), + ["first block", "second block"] + ); + assert_eq!(out.additional_context, ["ctx"]); + let prevent = out + .prevent_continuation + .as_ref() + .expect("force-stop captured"); + assert_eq!(prevent.hook_name, "s1"); + assert_eq!(prevent.reason, "stop now"); + } + + #[test] + fn absorb_empty_wants_no_continuation() { + let out = StopDispatchResult::default(); + assert!(!out.wants_continuation()); + assert!(out.prevent_continuation.is_none()); + } + + #[tokio::test] + async fn stop_collects_all_blocks() { + let registry = registry_from_specs(vec![ + stop_spec("b1", "echo '{\"decision\":\"block\",\"reason\":\"first\"}'"), + stop_spec("allow", "echo ok"), + stop_spec( + "b2", + "echo '{\"decision\":\"block\",\"reason\":\"second\"}'", + ), + ]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!(result.wants_continuation()); + assert_eq!( + result + .blocks + .iter() + .map(|b| b.reason.as_str()) + .collect::>(), + ["first", "second"] + ); + assert_eq!(result.results.len(), 3, "all hooks must have run"); + } + + #[tokio::test] + async fn stop_prevent_continuation_overrides_blocks() { + let registry = registry_from_specs(vec![ + stop_spec( + "blocker", + "echo '{\"decision\":\"block\",\"reason\":\"keep going\"}'", + ), + stop_spec( + "stopper", + "echo '{\"continue\":false,\"stopReason\":\"enough\"}'", + ), + ]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!(!result.wants_continuation()); + let prevent = result + .prevent_continuation + .expect("continue:false captured"); + assert_eq!(prevent.hook_name, "stopper"); + assert_eq!(prevent.reason, "enough"); + assert_eq!(result.blocks.len(), 1); + } + + #[tokio::test] + async fn stop_exit2_fail_open_and_context() { + let registry = registry_from_specs(vec![ + stop_spec("exit2", "echo 'fix the build' >&2; exit 2"), + stop_spec("crasher", "exit 1"), + stop_spec( + "ctx", + "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"note\"}}'", + ), + ]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!(result.wants_continuation()); + assert_eq!(result.blocks.len(), 1); + assert_eq!(result.blocks[0].reason, "fix the build"); + assert_eq!(result.additional_context, ["note"]); + } + + #[tokio::test] + async fn stop_additional_context_only_keeps_working() { + let registry = registry_from_specs(vec![stop_spec( + "ctx", + "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"run the tests\"}}'", + )]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!( + result.wants_continuation(), + "context alone must keep working" + ); + assert!(result.blocks.is_empty()); + assert!(result.prevent_continuation.is_none()); + assert_eq!(result.additional_context, ["run the tests"]); + } + + /// A timed-out stop hook fails open: stdout of a killed hook is never + /// interpreted, so a block written before hanging is ignored. + #[tokio::test] + async fn stop_timeout_fails_open() { + let mut spec = stop_spec( + "slow", + "echo '{\"decision\":\"block\",\"reason\":\"late\"}'; sleep 5", + ); + spec.timeout_ms = 200; + let registry = registry_from_specs(vec![spec]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!( + !result.wants_continuation(), + "timeout must not block the stop" + ); + assert!( + matches!(&result.results[0], HookRunResult::Failed { .. }), + "the timeout is recorded as a failure, got {:?}", + result.results[0] + ); + } + + #[tokio::test] + async fn stop_empty_and_allowing_registries_allow_stop() { + let registry = registry_from_specs(vec![]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!(!result.wants_continuation()); + assert!(result.results.is_empty()); + + let registry = registry_from_specs(vec![stop_spec("ok", "echo done")]); + let result = + dispatch_stop(®istry, HookEventName::Stop, &stop_envelope(), &run_ctx()).await; + assert!(!result.wants_continuation()); + } + + #[tokio::test] + async fn subagent_stop_consults_alias_specs() { + let mut canonical = make_command_spec( + "canonical", + None, + true, + "echo '{\"decision\":\"block\",\"reason\":\"from canonical\"}'", + ); + canonical.event = HookEventName::SubagentStop; + let mut alias = make_command_spec( + "alias", + None, + true, + "echo '{\"decision\":\"block\",\"reason\":\"from alias\"}'", + ); + alias.event = HookEventName::SubagentEnd; + let registry = registry_from_specs(vec![canonical, alias]); + + let mut envelope = stop_envelope(); + envelope.hook_event_name = HookEventName::SubagentStop; + envelope.payload = HookPayload::SubagentStop { + phase: crate::event::SubagentStopPhase::Gate, + subagent_id: "sub-1".into(), + subagent_type: "explore".into(), + stop_hook_active: Some(false), + last_assistant_message: None, + }; + let result = dispatch_stop( + ®istry, + HookEventName::SubagentStop, + &envelope, + &run_ctx(), + ) + .await; + assert_eq!(result.blocks.len(), 2); + } + + #[tokio::test] + async fn subagent_stop_matcher_filters_by_agent_type() { + let mut reviewer = make_command_spec( + "reviewer", + Some("code-reviewer"), + true, + "echo '{\"decision\":\"block\",\"reason\":\"from reviewer\"}'", + ); + reviewer.event = HookEventName::SubagentStop; + let mut explorer = make_command_spec( + "explorer", + Some("explore"), + true, + "echo '{\"decision\":\"block\",\"reason\":\"from explorer\"}'", + ); + explorer.event = HookEventName::SubagentStop; + let registry = registry_from_specs(vec![reviewer, explorer]); + + let mut envelope = stop_envelope(); + envelope.hook_event_name = HookEventName::SubagentStop; + envelope.payload = HookPayload::SubagentStop { + phase: crate::event::SubagentStopPhase::Gate, + subagent_id: "sub-1".into(), + subagent_type: "explore".into(), + stop_hook_active: Some(false), + last_assistant_message: None, + }; + let result = dispatch_stop( + ®istry, + HookEventName::SubagentStop, + &envelope, + &run_ctx(), + ) + .await; + assert_eq!(result.blocks.len(), 1, "only the matching spec runs"); + assert_eq!(result.blocks[0].reason, "from explorer"); + } #[tokio::test] async fn non_blocking_empty_registry() { @@ -790,23 +1078,6 @@ mod tests { assert!(matches!(results[0], HookRunResult::Skipped { .. })); } - #[tokio::test] - async fn non_blocking_success() { - let mut spec = make_command_spec("starter", None, true, "echo ok"); - spec.event = HookEventName::SessionStart; - let registry = registry_from_specs(vec![spec]); - let envelope = session_start_envelope(); - let results = dispatch_non_blocking( - ®istry, - HookEventName::SessionStart, - &envelope, - &run_ctx(), - ) - .await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], HookRunResult::Success { .. })); - } - #[tokio::test] async fn non_blocking_failure_does_not_stop_chain() { let mut spec1 = make_command_spec("crasher", None, true, "exit 1"); @@ -827,15 +1098,10 @@ mod tests { assert!(matches!(results[1], HookRunResult::Success { .. })); } - // ── hub_hook_kind tests ────────────────────────────────────── - #[test] - fn hub_hook_kind_returns_none_for_pre_tool_use() { + fn hub_hook_kind_maps_all_hub_forwarded_events() { assert_eq!(hub_hook_kind(HookEventName::PreToolUse), None); - } - #[test] - fn hub_hook_kind_maps_all_non_blocking_events() { let cases: &[(HookEventName, &str)] = &[ (HookEventName::SessionStart, "hook.session_start"), (HookEventName::SessionEnd, "hook.session_end"), @@ -856,7 +1122,7 @@ mod tests { (HookEventName::PostCompact, "hook.post_compact"), ]; - // Exhaustive match — adding a new HookEventName variant causes a + // Exhaustive match: adding a new HookEventName variant causes a // compiler error here, forcing this test to be updated. let total_variants = |e: HookEventName| -> usize { match e { diff --git a/crates/codegen/xai-grok-hooks/src/error.rs b/crates/codegen/xai-grok-hooks/src/error.rs index dc068a9..cff753f 100644 --- a/crates/codegen/xai-grok-hooks/src/error.rs +++ b/crates/codegen/xai-grok-hooks/src/error.rs @@ -19,13 +19,6 @@ pub enum HookError { source: regex::Error, }, - #[error("hook {name} in {path}: lifecycle hooks ({event}) must not specify a matcher in v0")] - LifecycleMatcherNotAllowed { - name: String, - path: PathBuf, - event: String, - }, - #[error("hook {name} timed out after {elapsed_ms}ms")] Timeout { name: String, elapsed_ms: u64 }, @@ -49,7 +42,7 @@ pub enum HookError { }, #[error( - "hook {name} in {path}: unsupported handler type '{handler_type}', only 'command' is supported in v0" + "hook {name} in {path}: unsupported handler type '{handler_type}', expected 'command' or 'http'" )] UnsupportedHandlerType { name: String, diff --git a/crates/codegen/xai-grok-hooks/src/event.rs b/crates/codegen/xai-grok-hooks/src/event.rs index c3d147c..59a5cb6 100644 --- a/crates/codegen/xai-grok-hooks/src/event.rs +++ b/crates/codegen/xai-grok-hooks/src/event.rs @@ -5,46 +5,32 @@ pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024; /// Hook event types. /// -/// Accepts both PascalCase (`"PreToolUse"`) and snake_case (`"pre_tool_use"`) -/// during deserialization for migration compatibility. -/// Serializes to snake_case for the hook envelope wire format. +/// Deserialization accepts PascalCase, snake_case, camelCase, and per-operation +/// aliases (e.g. `beforeShellExecution` maps to `PreToolUse`); see the `Deserialize` impl. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "snake_case")] pub enum HookEventName { - // ── Session lifecycle ─────────────────────────────────────── SessionStart, SessionEnd, - /// Fires when an agent turn ends (completed, cancelled, or error). + /// Fires on a genuine turn-end with stop decision control (a hook can block); + /// not on user interrupts (API-error turns fire `StopFailure`); observe-only at session end. Stop, /// Fires when the turn ends due to an API error. Output and exit code are ignored. StopFailure, - // ── Tool events ───────────────────────────────────────────── PreToolUse, PostToolUse, - /// Fires after a tool call fails (throws an error). PostToolUseFailure, - /// Fires when a tool call is denied by the permission system. PermissionDenied, - // ── User / notification events ────────────────────────────── - /// Fires when the user submits a prompt. UserPromptSubmit, - /// Fires when a notification is sent (e.g., permission prompt, idle). Notification, - // ── Subagent events ───────────────────────────────────────── - /// Fires when a subagent is spawned. SubagentStart, - /// Fires when a subagent completes. SubagentStop, - /// Alias for SubagentStop (kept for backward compatibility). SubagentEnd, - // ── Compaction events ─────────────────────────────────────── - /// Fires before context compaction. PreCompact, - /// Fires after context compaction completes. PostCompact, } @@ -57,7 +43,7 @@ impl<'de> serde::Deserialize<'de> for HookEventName { match s.as_str() { // PascalCase (native) + snake_case + camelCase (third-party compat). // Per-operation hook names (beforeShellExecution, afterFileEdit, etc.) - // map to our generic PreToolUse/PostToolUse — the hook script receives the + // map to our generic PreToolUse/PostToolUse; the hook script receives the // tool name in JSON input and can filter, or use the `matcher` field. "SessionStart" | "session_start" | "sessionStart" => Ok(Self::SessionStart), "PreToolUse" @@ -97,7 +83,8 @@ impl<'de> serde::Deserialize<'de> for HookEventName { SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \ SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \ PermissionDenied, SubagentStart, SubagentStop, \ - PreCompact, PostCompact" + PreCompact, PostCompact (camelCase and per-operation aliases \ + such as beforeShellExecution are also accepted)" ))), } } @@ -124,6 +111,31 @@ impl std::fmt::Display for HookEventName { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateKind { + /// Hook output recorded, decisions ignored. + Observe, + Tool, + /// Stop decision control (`block`, `continue: false`, `additionalContext`). + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatcherPolicy { + /// Never evaluated: kept for display with a load-time warning, the hook fires on every occurrence. + Ignored, + /// Tested against the value [`HookPayload::match_value`] extracts from the payload. + Tested, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventTraits { + pub gate: GateKind, + pub matcher: MatcherPolicy, + /// Whether hub custom hooks receive this event (see `dispatcher::hub_hook_kind`). + pub hub_forward: bool, +} + impl HookEventName { /// Collapse alias variants to their canonical form so a registration and the fired /// event meet on one key regardless of which spelling each used (`SubagentEnd` is an @@ -135,24 +147,124 @@ impl HookEventName { } } - /// Returns true if this event type uses blocking (deny/allow) semantics. - pub fn is_blocking(&self) -> bool { - matches!(self, Self::PreToolUse) - } - - /// Events that don't support matcher patterns (fire on every occurrence). - pub fn is_lifecycle(&self) -> bool { - matches!( - self, - Self::SessionStart | Self::SessionEnd | Self::Stop | Self::UserPromptSubmit - ) + /// The event's dispatch traits. Exhaustive on purpose: a new variant fails to + /// compile until its gate, matcher, and hub forwarding are chosen here. + pub fn traits(self) -> EventTraits { + use GateKind::*; + use MatcherPolicy::*; + let t = |gate, matcher, hub_forward| EventTraits { + gate, + matcher, + hub_forward, + }; + match self.canonical() { + Self::SessionStart => t(Observe, Tested, true), + Self::SessionEnd => t(Observe, Tested, true), + Self::Stop => t(Stop, Ignored, true), + Self::StopFailure => t(Observe, Tested, true), + Self::PreToolUse => t(Tool, Tested, false), + Self::PostToolUse => t(Observe, Tested, true), + Self::PostToolUseFailure => t(Observe, Tested, true), + Self::PermissionDenied => t(Observe, Tested, true), + Self::UserPromptSubmit => t(Observe, Ignored, true), + Self::Notification => t(Observe, Tested, true), + Self::SubagentStart => t(Observe, Tested, true), + Self::SubagentStop => t(Stop, Tested, true), + Self::SubagentEnd => unreachable!("canonicalized above"), + Self::PreCompact => t(Observe, Tested, true), + Self::PostCompact => t(Observe, Tested, true), + } } } -/// The normalized event envelope sent to hook commands on stdin as JSON. -/// -/// Contains common metadata plus an event-specific payload. -/// All field names use camelCase for the JSON wire format. +/// Max characters for free-text fields in `StopBackgroundTask`/`StopSessionCron` entries. +pub const MAX_STOP_ENTRY_TEXT_CHARS: usize = 1000; + +/// Clip `text` to `max` chars (on a char boundary) with a `… [+N chars]` marker. +pub fn clip_text(text: &str, max: usize) -> String { + let char_count = text.chars().count(); + if char_count <= max { + return text.to_string(); + } + let clipped: String = text.chars().take(max).collect(); + format!("{clipped}… [+{} chars]", char_count - max) +} + +pub fn clip_stop_entry_text(text: &str) -> String { + clip_text(text, MAX_STOP_ENTRY_TEXT_CHARS) +} + +/// `SubagentStop` fire phase: always `Gate` today, `Observe` reserved and not emitted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SubagentStopPhase { + Gate, + Observe, +} + +/// One in-flight background task in a `Stop` hook input (camelCase on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopBackgroundTask { + pub id: String, + pub r#type: BackgroundTaskType, + /// Always `running` for in-flight entries. + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, +} + +/// One session-scoped scheduled wakeup (scheduler task or `/loop`) in a `Stop` hook input. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopSessionCron { + pub id: String, + /// Human-readable interval (e.g. `every 5 minutes`): grok schedules are intervals, not cron. + pub schedule: String, + pub recurring: bool, + pub prompt: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundTaskType { + Shell, + Monitor, + Subagent, +} + +/// `StopFailure` error type. Grok emits a subset: capacity errors fold into +/// `RateLimit`, and there is no `billing_error`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StopFailureKind { + RateLimit, + AuthenticationFailed, + InvalidRequest, + ServerError, + MaxOutputTokens, + Unknown, +} + +impl StopFailureKind { + pub fn as_str(self) -> &'static str { + match self { + Self::RateLimit => "rate_limit", + Self::AuthenticationFailed => "authentication_failed", + Self::InvalidRequest => "invalid_request", + Self::ServerError => "server_error", + Self::MaxOutputTokens => "max_output_tokens", + Self::Unknown => "unknown", + } + } +} + +/// The normalized event envelope sent to hook commands on stdin as JSON: +/// common metadata plus an event-specific payload. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HookEventEnvelope { @@ -167,16 +279,17 @@ pub struct HookEventEnvelope { pub client_identifier: Option, #[serde(skip_serializing_if = "Option::is_none")] pub prompt_id: Option, + /// Session permission mode (`default`, `auto`, `plan`, `bypassPermissions`) at fire time. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, #[serde(flatten)] pub payload: HookPayload, } -/// Event-specific payload variants, flattened into the envelope JSON via -/// `#[serde(untagged)]`. Grouped to match `HookEventName`. +/// Event-specific payload, flattened into the envelope JSON. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] pub enum HookPayload { - // ── Session lifecycle ─────────────────────────────────────── SessionStart { source: String, #[serde(rename = "modelId", skip_serializing_if = "Option::is_none")] @@ -193,16 +306,40 @@ pub enum HookPayload { }, Stop { reason: String, + /// True when this Stop fires while the agent is already continuing from a + /// previous Stop-hook block this turn; hooks check it to avoid blocking on a + /// condition that will never resolve. + #[serde(rename = "stopHookActive")] + stop_hook_active: bool, + #[serde( + rename = "lastAssistantMessage", + skip_serializing_if = "Option::is_none" + )] + last_assistant_message: Option, + /// In-flight background work that could wake the session; empty when none in + /// flight, omitted (not empty) at fire sites that don't enumerate (session end). + #[serde(rename = "backgroundTasks", skip_serializing_if = "Option::is_none")] + background_tasks: Option>, + #[serde(rename = "sessionCrons", skip_serializing_if = "Option::is_none")] + session_crons: Option>, }, StopFailure { - error: String, + error: StopFailureKind, + #[serde(rename = "errorDetails", skip_serializing_if = "Option::is_none")] + error_details: Option, + /// Rendered error text shown in the conversation: unlike `Stop`, the error + /// string, not assistant output. + #[serde( + rename = "lastAssistantMessage", + skip_serializing_if = "Option::is_none" + )] + last_assistant_message: Option, }, - // ── Tool events ───────────────────────────────────────────── PreToolUse { /// The tool the model invoked. For the meta-dispatch tools (`use_tool` /// and the external MCP-call tool) this is the resolved underlying tool - /// (`server__tool`), not the dispatcher — matchers key on it directly. + /// (`server__tool`) rather than the dispatcher, so matchers key on it. #[serde(rename = "toolName")] tool_name: String, #[serde(rename = "toolUseId")] @@ -211,8 +348,6 @@ pub enum HookPayload { tool_input: serde_json::Value, #[serde(rename = "toolInputTruncated")] tool_input_truncated: bool, - #[serde(rename = "permissionMode", skip_serializing_if = "Option::is_none")] - permission_mode: Option, /// The subagent's type when this tool runs inside one (the envelope's `sessionId` /// gives its identity); `None` for the top-level session. #[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")] @@ -265,13 +400,10 @@ pub enum HookPayload { tool_input_truncated: bool, }, - // ── User / notification events ────────────────────────────── - /// Fires when the user submits a prompt. UserPromptSubmit { #[serde(skip_serializing_if = "Option::is_none")] prompt: Option, }, - /// Fires on agent notifications (permission prompts, idle, etc.). Notification { #[serde(rename = "notificationType")] notification_type: String, @@ -284,8 +416,6 @@ pub enum HookPayload { level: Option, }, - // ── Subagent events ───────────────────────────────────────── - /// Fires when a subagent is spawned. SubagentStart { #[serde(rename = "subagentId")] subagent_id: String, @@ -294,21 +424,22 @@ pub enum HookPayload { #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, - /// Fires when a subagent completes. SubagentStop { + phase: SubagentStopPhase, #[serde(rename = "subagentId")] subagent_id: String, #[serde(rename = "subagentType")] subagent_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")] - exit_code: Option, - #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")] - duration_ms: Option, + /// Subagent analogue of `Stop::stop_hook_active`. + #[serde(rename = "stopHookActive", skip_serializing_if = "Option::is_none")] + stop_hook_active: Option, + #[serde( + rename = "lastAssistantMessage", + skip_serializing_if = "Option::is_none" + )] + last_assistant_message: Option, }, - // ── Compaction events ─────────────────────────────────────── PreCompact { /// "manual" or "auto". source: String, @@ -319,6 +450,33 @@ pub enum HookPayload { }, } +impl HookPayload { + /// The value a [`MatcherPolicy::Tested`] matcher is tested against, or `None` when + /// the payload carries nothing selectable (matchers then fire-all, the fail-open default). + pub fn match_value(&self) -> Option<&str> { + let value = match self { + Self::PreToolUse { tool_name, .. } + | Self::PostToolUse { tool_name, .. } + | Self::PostToolUseFailure { tool_name, .. } + | Self::PermissionDenied { tool_name, .. } => tool_name, + Self::Notification { + notification_type, .. + } => notification_type, + Self::SubagentStart { subagent_type, .. } + | Self::SubagentStop { subagent_type, .. } => subagent_type, + Self::SessionStart { source, .. } + | Self::PreCompact { source } + | Self::PostCompact { source } => source, + Self::SessionEnd { reason, .. } => reason, + // Always a non-empty name, unlike the free-text arms above. + Self::StopFailure { error, .. } => return Some(error.as_str()), + // Ignored events listed explicitly so a new Tested event can't silently return None. + Self::Stop { .. } | Self::UserPromptSubmit { .. } => return None, + }; + Some(value.as_str()).filter(|v| !v.is_empty()) + } +} + /// Truncate a JSON value if its serialized size exceeds `MAX_PAYLOAD_SIZE`. /// /// Returns `(possibly_truncated_value, was_truncated)`. @@ -417,12 +575,29 @@ mod tests { } #[test] - fn event_name_serde_roundtrip() { - let name = HookEventName::PreToolUse; - let json = serde_json::to_string(&name).unwrap(); - assert_eq!(json, "\"pre_tool_use\""); - let parsed: HookEventName = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, name); + fn event_name_deser_camel_and_operation_aliases() { + let cases: &[(&str, HookEventName)] = &[ + ("sessionStart", HookEventName::SessionStart), + ("preToolUse", HookEventName::PreToolUse), + ("beforeShellExecution", HookEventName::PreToolUse), + ("beforeMCPExecution", HookEventName::PreToolUse), + ("beforeReadFile", HookEventName::PreToolUse), + ("postToolUse", HookEventName::PostToolUse), + ("afterShellExecution", HookEventName::PostToolUse), + ("afterMCPExecution", HookEventName::PostToolUse), + ("afterFileEdit", HookEventName::PostToolUse), + ("afterAgentResponse", HookEventName::PostToolUse), + ("afterAgentThought", HookEventName::PostToolUse), + ("beforeSubmitPrompt", HookEventName::UserPromptSubmit), + ("subagentStop", HookEventName::SubagentStop), + ("subagentEnd", HookEventName::SubagentEnd), + ("preCompact", HookEventName::PreCompact), + ("stopFailure", HookEventName::StopFailure), + ]; + for (spelling, expected) in cases { + let parsed: HookEventName = serde_json::from_str(&format!("\"{spelling}\"")).unwrap(); + assert_eq!(parsed, *expected, "alias deser failed for {spelling}"); + } } #[test] @@ -432,57 +607,134 @@ mod tests { } #[test] - fn event_name_is_blocking() { - assert!(HookEventName::PreToolUse.is_blocking()); - for event in [ - HookEventName::SessionStart, - HookEventName::PostToolUse, - HookEventName::PostToolUseFailure, - HookEventName::SessionEnd, - HookEventName::Stop, - HookEventName::StopFailure, - HookEventName::Notification, - HookEventName::UserPromptSubmit, - HookEventName::PermissionDenied, - HookEventName::SubagentStart, - HookEventName::SubagentStop, - HookEventName::SubagentEnd, - HookEventName::PreCompact, - HookEventName::PostCompact, - ] { - assert!(!event.is_blocking(), "{event:?} should not be blocking"); - } + fn event_traits_report_gate_matcher_and_hub_forward() { + use super::{GateKind, MatcherPolicy}; + + assert_eq!(HookEventName::PreToolUse.traits().gate, GateKind::Tool); + assert_eq!(HookEventName::Stop.traits().gate, GateKind::Stop); + assert_eq!(HookEventName::SubagentStop.traits().gate, GateKind::Stop); + assert_eq!( + HookEventName::SubagentEnd.traits().gate, + GateKind::Stop, + "alias resolves through canonical()" + ); + assert_eq!(HookEventName::PostToolUse.traits().gate, GateKind::Observe); + + assert_eq!(HookEventName::Stop.traits().matcher, MatcherPolicy::Ignored); + assert_eq!( + HookEventName::UserPromptSubmit.traits().matcher, + MatcherPolicy::Ignored + ); + assert_eq!( + HookEventName::SessionStart.traits().matcher, + MatcherPolicy::Tested + ); + + assert!(!HookEventName::PreToolUse.traits().hub_forward); + assert!(HookEventName::Stop.traits().hub_forward); } #[test] - fn event_name_is_lifecycle() { - let lifecycle = [ - HookEventName::SessionStart, - HookEventName::SessionEnd, - HookEventName::Stop, - HookEventName::UserPromptSubmit, - ]; - for event in lifecycle { - assert!(event.is_lifecycle(), "{event:?} should be lifecycle"); - } + fn clip_stop_entry_text_clips_on_char_boundary() { + assert_eq!(clip_stop_entry_text("short"), "short"); + let exact = "x".repeat(MAX_STOP_ENTRY_TEXT_CHARS); + assert_eq!(clip_stop_entry_text(&exact), exact); - let matchable = [ - HookEventName::PreToolUse, - HookEventName::PostToolUse, - HookEventName::PostToolUseFailure, - HookEventName::PermissionDenied, - HookEventName::StopFailure, - HookEventName::Notification, - HookEventName::SubagentStart, - HookEventName::SubagentStop, - HookEventName::SubagentEnd, - HookEventName::PreCompact, - HookEventName::PostCompact, - ]; - for event in matchable { - assert!( - !event.is_lifecycle(), - "{event:?} should support matchers, not be lifecycle" + let long = "x".repeat(MAX_STOP_ENTRY_TEXT_CHARS + 42); + let clipped = clip_stop_entry_text(&long); + assert!(clipped.ends_with("… [+42 chars]")); + + let unicode = "€".repeat(MAX_STOP_ENTRY_TEXT_CHARS + 7); + let clipped = clip_stop_entry_text(&unicode); + assert!(clipped.ends_with("… [+7 chars]")); + } + + #[test] + fn stop_payload_serializes_task_and_cron_entries() { + let envelope = HookEventEnvelope { + hook_event_name: HookEventName::Stop, + session_id: "s".into(), + cwd: "/tmp".into(), + workspace_root: "/tmp".into(), + timestamp: "t".into(), + transcript_path: None, + client_identifier: None, + prompt_id: None, + permission_mode: None, + payload: HookPayload::Stop { + reason: "end_turn".into(), + stop_hook_active: true, + last_assistant_message: Some("done".into()), + background_tasks: Some(vec![ + StopBackgroundTask { + id: "task-001".into(), + r#type: BackgroundTaskType::Shell, + status: "running".into(), + description: None, + command: Some("tail -f /var/log/syslog".into()), + agent_type: None, + }, + StopBackgroundTask { + id: "task-002".into(), + r#type: BackgroundTaskType::Subagent, + status: "running".into(), + description: Some("explore the repo".into()), + command: None, + agent_type: Some("explore".into()), + }, + ]), + session_crons: Some(vec![StopSessionCron { + id: "cron-001".into(), + schedule: "every 2h".into(), + recurring: true, + prompt: "check the build".into(), + }]), + }, + }; + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["stopHookActive"], true); + assert_eq!(value["backgroundTasks"][0]["id"], "task-001"); + assert_eq!(value["backgroundTasks"][0]["type"], "shell"); + assert_eq!( + value["backgroundTasks"][0]["command"], + "tail -f /var/log/syslog" + ); + assert_eq!(value["backgroundTasks"][1]["agentType"], "explore"); + assert_eq!(value["sessionCrons"][0]["schedule"], "every 2h"); + assert_eq!(value["sessionCrons"][0]["recurring"], true); + } + + #[test] + fn subagent_stop_phase_serializes_lowercase() { + let payload = HookPayload::SubagentStop { + phase: SubagentStopPhase::Observe, + subagent_id: "sub-1".into(), + subagent_type: "explore".into(), + stop_hook_active: None, + last_assistant_message: None, + }; + let value = serde_json::to_value(&payload).unwrap(); + assert_eq!(value["phase"], "observe"); + assert_eq!( + serde_json::to_value(SubagentStopPhase::Gate).unwrap(), + "gate" + ); + } + + #[test] + fn stop_failure_kind_as_str_matches_serialization() { + for kind in [ + StopFailureKind::RateLimit, + StopFailureKind::AuthenticationFailed, + StopFailureKind::InvalidRequest, + StopFailureKind::ServerError, + StopFailureKind::MaxOutputTokens, + StopFailureKind::Unknown, + ] { + assert_eq!( + serde_json::to_value(kind).unwrap(), + serde_json::Value::from(kind.as_str()), + "{kind:?} serialization drifted from as_str" ); } } @@ -497,23 +749,18 @@ mod tests { #[test] fn truncate_large_payload() { - let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1000); - let value = serde_json::Value::String(big_string); + let value = serde_json::Value::String("x".repeat(MAX_PAYLOAD_SIZE + 1000)); let (result, truncated) = truncate_payload(value); assert!(truncated); let s = result.as_str().unwrap(); assert!(s.ends_with("[truncated]")); - // Serialized size of the result string value should be <= MAX_PAYLOAD_SIZE + overhead assert!(s.len() < MAX_PAYLOAD_SIZE + 100); - } - #[test] - fn truncate_large_payload_cuts_on_char_boundary() { - // '€' is 3 bytes, so the MAX_PAYLOAD_SIZE-th byte lands mid-codepoint. - let value = serde_json::Value::String("€".repeat(MAX_PAYLOAD_SIZE)); - let (result, truncated) = truncate_payload(value); + // '€' is 3 bytes, so the cut lands mid-codepoint and must fall back to a char boundary. + let (unicode, truncated) = + truncate_payload(serde_json::Value::String("€".repeat(MAX_PAYLOAD_SIZE))); assert!(truncated); - assert!(result.as_str().unwrap().ends_with("[truncated]")); + assert!(unicode.as_str().unwrap().ends_with("[truncated]")); } #[test] @@ -527,19 +774,19 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::SessionStart { source: "new".into(), model_id: Some("grok-3".into()), agent_type: None, }, }; - let json = serde_json::to_string(&envelope).unwrap(); - assert!(json.contains("hookEventName")); - assert!(json.contains("sessionId")); - assert!(json.contains("workspaceRoot")); - assert!(json.contains("modelId")); - // Should NOT contain snake_case versions - assert!(!json.contains("hook_event_name")); - assert!(!json.contains("session_id")); + let value = serde_json::to_value(&envelope).unwrap(); + for key in ["hookEventName", "sessionId", "workspaceRoot", "modelId"] { + assert!(value.get(key).is_some(), "missing camelCase key {key}"); + } + for key in ["hook_event_name", "session_id", "model_id"] { + assert!(value.get(key).is_none(), "leaked snake_case key {key}"); + } } } diff --git a/crates/codegen/xai-grok-hooks/src/matcher.rs b/crates/codegen/xai-grok-hooks/src/matcher.rs index 687c031..57784ae 100644 --- a/crates/codegen/xai-grok-hooks/src/matcher.rs +++ b/crates/codegen/xai-grok-hooks/src/matcher.rs @@ -22,7 +22,7 @@ pub struct HookMatcher { enum MatcherKind { All, /// Matches no tool names. Used when a configured matcher fails to compile - /// after deserialization — fail closed rather than widen to match-all. + /// after deserialization; fail closed rather than widen to match-all. Never, Exact(Vec), Regex(Regex), @@ -63,6 +63,15 @@ impl HookMatcher { } } +/// Shared matcher-application rule: a missing matcher or missing value fires +/// (fail-open); otherwise the compiled matcher decides. +pub fn matcher_allows(matcher: Option<&HookMatcher>, value: Option<&str>) -> bool { + match (matcher, value) { + (Some(matcher), Some(value)) => matcher.is_match(value), + _ => true, + } +} + /// A pattern is "simple" (exact/`|`-list, not regex) when it contains only /// ASCII alphanumerics, `_`, and `|`. fn is_simple_form(pattern: &str) -> bool { @@ -173,8 +182,6 @@ mod tests { assert!(!m.is_match("run_terminal_command")); } - // ── External tool-name aliases ──────────────────────────────── - #[test] fn claude_bash_matches_grok_tool() { let m = HookMatcher::new("Bash").unwrap(); @@ -198,14 +205,6 @@ mod tests { assert!(!m.is_match("my_search_replace")); } - #[test] - fn claude_read_matches_grok_tool() { - let m = HookMatcher::new("Read").unwrap(); - assert!(m.is_match("Read")); - assert!(m.is_match("read_file")); - assert!(m.is_match("hashline_read")); - } - #[test] fn regex_against_claude_alias_matches_grok_tool() { // A regex written against an external alias still matches the Grok tool diff --git a/crates/codegen/xai-grok-hooks/src/result.rs b/crates/codegen/xai-grok-hooks/src/result.rs index 9932d19..b31b411 100644 --- a/crates/codegen/xai-grok-hooks/src/result.rs +++ b/crates/codegen/xai-grok-hooks/src/result.rs @@ -3,68 +3,70 @@ use std::time::Duration; /// The outcome of a blocking (`pre_tool_use`) hook dispatch. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HookDecision { - /// All hooks allowed (or no hooks matched). Allow, - /// At least one hook denied with the given reason. Deny { reason: String, hook_name: String }, } -/// HTTP-specific execution details for scrollback enrichment. -/// -/// Populated only for `"http"` handler type hooks. Carries the target -/// URL, HTTP status, and a short preview of the response body so that -/// scrollback annotations can display them. +/// Parsed output of one `Stop`/`SubagentStop` gate hook. The dispatcher +/// aggregates these across hooks; `force_stop` overrides blocks. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StopHookOutcome { + pub block_reason: Option, + pub additional_context: Option, + pub force_stop: Option, +} + +/// A `continue: false` force-stop; `reason` is `stopReason`, shown to the user. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StopOverride { + pub reason: Option, +} + +impl StopHookOutcome { + pub fn is_empty(&self) -> bool { + self.block_reason.is_none() + && self.additional_context.is_none() + && self.force_stop.is_none() + } +} + +/// HTTP execution details for `"http"` hooks, for scrollback enrichment. #[derive(Debug, Clone)] pub struct HttpInfo { - /// The URL that was POSTed to. - /// - /// **Post-expansion form**: this is the actual target the runner - /// hit (or attempted to hit) and is intended for SSRF debugging. - /// User `env` map values resolved at expand time can land here, so - /// any new wire-DTO consumer that surfaces this field for **user - /// display** MUST prefer [`raw_url`] when available -- otherwise - /// secrets like API tokens embedded in the URL via `${TOKEN}` - /// substitution will leak. See `HookSpec::url_raw` in - /// `crate::config` for the parallel display-vs-execution split. - /// - /// [`raw_url`]: HttpInfo::raw_url + /// Post-expansion target (for SSRF debugging). May contain secrets from + /// resolved `${VAR}` substitutions, so user-facing display MUST prefer + /// `raw_url` when present. pub url: String, - /// Pre-expansion source URL exactly as written in the JSON file, - /// when available. Mirrors `HookSpec::url_raw` so downstream wire - /// DTOs / scrollback display layers can show the source string - /// without ever leaking resolved `${VAR}` substitutions. `None` - /// for legacy code paths that constructed the spec without the - /// raw source (the runner falls back to displaying [`url`] in - /// that case). - /// - /// [`url`]: HttpInfo::url + /// Pre-expansion source URL as written in the file, safe for display. + /// `None` when the spec was built without it (fall back to `url`). pub raw_url: Option, - /// HTTP status code (e.g. 200, 500). `None` if the request never - /// completed (timeout, connection error). pub status: Option, - /// Short preview of the response body (truncated to ~200 chars). - /// `None` if no body was read (e.g. non-blocking hooks, timeouts). pub response_preview: Option, } /// The outcome of a single hook execution. #[derive(Debug)] pub enum HookRunResult { - /// Hook executed successfully. Success { hook_name: String, elapsed: Duration, - /// HTTP details, populated only for `"http"` handler type hooks. http_info: Option, }, - /// Hook was skipped because it is disabled. - Skipped { hook_name: String }, - /// Hook failed (timeout, crash, bad output, etc.) — fail-open. + Skipped { + hook_name: String, + }, + /// Ran and blocked: a stop-gate decision, not a failure (distinct from `Failed`). + Blocked { + hook_name: String, + detail: String, + elapsed: Duration, + http_info: Option, + }, + /// Hook failed (timeout, crash, bad output): fail-open. Failed { hook_name: String, error: String, elapsed: Duration, - /// HTTP details, populated only for `"http"` handler type hooks. http_info: Option, }, } diff --git a/crates/codegen/xai-grok-hooks/src/runner/command.rs b/crates/codegen/xai-grok-hooks/src/runner/command.rs index e9a122c..94a8920 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/command.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/command.rs @@ -1,27 +1,22 @@ use std::time::{Duration, Instant}; -use serde::Deserialize; use tokio::io::AsyncWriteExt; use crate::config::HookSpec; use crate::event::HookEventEnvelope; -use crate::result::HookDecision; +use crate::result::{HookDecision, StopHookOutcome}; -use super::{HookRunnerResult, RunContext}; +use super::{ + GateHookJson, GateKind, HookRunnerResult, RunContext, StopHookJson, gate_json_to_decision, + stop_json_to_outcome, +}; /// Maximum bytes to capture from hook stdout or stderr (64 KB). const MAX_OUTPUT_BYTES: usize = 64 * 1024; -/// Exit code that a blocking hook uses to signal an explicit deny. -const DENY_EXIT_CODE: i32 = 2; - -/// The JSON result structure expected from blocking hooks. -#[derive(Debug, Deserialize)] -struct HookOutput { - decision: String, - #[serde(default)] - reason: Option, -} +/// Exit code that a blocking hook uses to signal an explicit deny (PreToolUse) +/// or block (Stop/SubagentStop, with stderr as the feedback). +const GATE_EXIT_CODE: i32 = 2; /// Run a single hook command. /// @@ -32,7 +27,7 @@ pub async fn run_command_hook( spec: &HookSpec, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, - is_blocking: bool, + mode: GateKind, ) -> (HookRunnerResult, Duration) { let start = Instant::now(); @@ -44,7 +39,6 @@ pub async fn run_command_hook( }; let command_str = command.to_string_lossy(); - // Serialize envelope to JSON. let stdin_json = match serde_json::to_string(envelope) { Ok(j) => j, Err(e) => { @@ -56,7 +50,6 @@ pub async fn run_command_hook( } }; - // Check opt-in debug logging. let debug_payloads = std::env::var("GROK_HOOK_DEBUG").is_ok_and(|v| v == "1"); if debug_payloads { tracing::trace!( @@ -66,15 +59,10 @@ pub async fn run_command_hook( ); } - // Determine how to spawn the command. - // - // If the command contains shell metacharacters (spaces, pipes, &&, ||, - // redirects, semicolons, env-var refs) or starts with `~` (tilde - // expansion), run it through `sh -c` so that shell command strings - // from compatible configs work correctly. - // - // Otherwise, treat it as a direct executable path (resolve relative - // paths from the hook file's directory). + // Commands with shell metacharacters (spaces, pipes, &&, ||, redirects, + // semicolons, env-var refs) or a leading `~` run through `sh -c` so shell + // command strings from compatible configs work; everything else is a + // direct executable path resolved from the hook file's directory. let is_shell_command = command_str.contains(' ') || command_str.contains('|') || command_str.contains('&') @@ -85,18 +73,10 @@ pub async fn run_command_hook( || command_str.starts_with('~'); let mut cmd = if is_shell_command { - // Refuse to spawn when the command interpolates an env var that - // we can't resolve from any of: the runner's always-set vars, the - // per-hook extra_env (plugin vars), or Grok's own process env. The - // alternative is letting sh expand the var to empty -- which then - // produces a broken command, exits 127, and (for PreToolUse hooks) - // fails closed with an opaque "exit code 127" reason. Catching it - // here gives the model a clear actionable error and skips the - // wasted fork+exec. - // - // Vars with a parameter-expansion modifier (`${VAR:-default}`, - // `${VAR-x}`, `${VAR:=x}`, `${VAR:?msg}`, `${VAR:+x}`, etc.) are - // NOT flagged: the user has explicitly handled the unset case. + // Fail fast on env vars we can't resolve (runner vars, per-hook + // extra_env, or process env). Letting sh expand them to empty yields a + // broken command that exits 127 with an opaque reason; surface a clear + // error instead. let unresolved = find_unresolved_env_vars(&command_str, &spec.extra_env); if !unresolved.is_empty() { let elapsed = start.elapsed(); @@ -126,7 +106,6 @@ pub async fn run_command_hook( c } } else { - // Direct executable: resolve relative paths from source_dir. let command_path = if command.is_absolute() { command.clone() } else { @@ -142,11 +121,8 @@ pub async fn run_command_hook( tokio::process::Command::new(command_path) }; - // Detach from controlling terminal so child processes (e.g. GPG pinentry) - // cannot open /dev/tty and corrupt the TUI display. Delegates to - // `xai_grok_tools::util::detach_command`: Unix uses the same setsid / - // EPERM→setpgid pre_exec path as before; Windows sets CREATE_NO_WINDOW only - // (DETACHED_PROCESS is intentionally omitted — it breaks stdio inheritance). + // Detach from the controlling terminal so children (e.g. GPG pinentry) + // can't open /dev/tty and corrupt the TUI display. xai_grok_tools::util::detach_command(&mut cmd); // Spawn the child process. @@ -156,7 +132,7 @@ pub async fn run_command_hook( // the order matters: we MUST apply user/plugin `extra_env` FIRST and // the runner-injected vars LAST. Otherwise a user JSON hook (or a // plugin) can spoof `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, - // `GROK_WORKSPACE_ROOT`, or `CLAUDE_PROJECT_DIR` -- which are the + // `GROK_WORKSPACE_ROOT`, or `CLAUDE_PROJECT_DIR`, which are the // identity/event signals a hook script consumes for policy and audit. // See the `runner_injected_vars_override_extra_env_at_spawn` // regression test in `tests/integration.rs` and the rustdoc on @@ -168,7 +144,7 @@ pub async fn run_command_hook( .current_dir(ctx.workspace_root) // 1. user/plugin extra_env first (lowest precedence). .envs(&spec.extra_env) - // 2. runner-injected vars last (highest precedence -- always win). + // 2. runner-injected vars last (highest precedence, always win). .env("GROK_HOOK_EVENT", envelope.hook_event_name.to_string()) .env("GROK_HOOK_NAME", &spec.name) .env("GROK_SESSION_ID", ctx.session_id) @@ -190,21 +166,27 @@ pub async fn run_command_hook( } }; - // Write stdin and close. - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(stdin_json.as_bytes()).await; - drop(stdin); - } - - // Wait with timeout. + // Write stdin concurrently with draining output, under the timeout: a hook + // that never reads stdin would otherwise block `write_all` on a full pipe + // buffer, outside the deadline. + let stdin = child.stdin.take(); let timeout = Duration::from_millis(spec.timeout_ms); - let result = tokio::time::timeout(timeout, child.wait_with_output()).await; + let result = tokio::time::timeout(timeout, async move { + let write = async { + if let Some(mut stdin) = stdin { + let _ = stdin.write_all(stdin_json.as_bytes()).await; + } + }; + let (_, output) = tokio::join!(write, child.wait_with_output()); + output + }) + .await; let elapsed = start.elapsed(); match result { Err(_) => { - // Timeout — kill_on_drop handles cleanup. + // Timeout: kill_on_drop handles cleanup. ( HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)), elapsed, @@ -217,7 +199,6 @@ pub async fn run_command_hook( Ok(Ok(output)) => { let exit_code = output.status.code().unwrap_or(-1); - // Truncate stdout/stderr to buffer limits. let stdout = truncate_output(&output.stdout); let stderr = truncate_output(&output.stderr); @@ -246,18 +227,21 @@ pub async fn run_command_hook( "hook command completed" ); - if !is_blocking { - if exit_code == 0 { - return (HookRunnerResult::Success, elapsed); + match mode { + GateKind::Observe => { + if exit_code == 0 { + return (HookRunnerResult::Success, elapsed); + } + ( + HookRunnerResult::Failed(format!("exit code {exit_code}")), + elapsed, + ) + } + GateKind::Tool => parse_blocking_result(&stdout, exit_code, &spec.name, elapsed), + GateKind::Stop => { + parse_stop_result(&stdout, &stderr, exit_code, &spec.name, elapsed) } - return ( - HookRunnerResult::Failed(format!("exit code {exit_code}")), - elapsed, - ); } - - // Blocking hook: parse decision from stdout. - parse_blocking_result(&stdout, exit_code, &spec.name, elapsed) } } } @@ -307,10 +291,6 @@ fn find_unresolved_env_vars( ) -> Vec { let locally_assigned = find_local_shell_assignments(command_str); let mut out: Vec = Vec::new(); - // Delegate the byte-walking parser to the shared - // `iter_env_var_references` helper in `env_expand`. We only need to - // consider references where the user did NOT supply a parameter- - // expansion modifier (those explicitly handle the unset case). for r in crate::env_expand::iter_env_var_references(command_str) { if r.name.is_empty() || r.has_modifier { continue; @@ -360,7 +340,6 @@ fn find_local_shell_assignments(command_str: &str) -> std::collections::HashSet< if idx == 0 { return true; } - // Walk left over whitespace. let mut j = idx; while j > 0 { let c = bytes[j - 1]; @@ -378,7 +357,6 @@ fn find_local_shell_assignments(command_str: &str) -> std::collections::HashSet< i += 1; continue; } - // Read the identifier. let start = i; while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; @@ -387,17 +365,11 @@ fn find_local_shell_assignments(command_str: &str) -> std::collections::HashSet< if ident.is_empty() { continue; } - // VAR= (assignment): identifier must be at a statement-start - // boundary and immediately followed by '=' (no space). if i < bytes.len() && bytes[i] == b'=' && is_statement_start(start) { names.insert(ident.to_string()); continue; } - // `read VAR1 VAR2 ...`: collect every whitespace-separated bare - // identifier following a `read` keyword on the same statement. if ident == "read" && is_statement_start(start) { - // Skip whitespace, then read identifiers until we hit a - // statement separator or end of string. while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; } @@ -441,64 +413,51 @@ fn parse_blocking_result( hook_name: &str, elapsed: Duration, ) -> (HookRunnerResult, Duration) { - // Try to parse JSON output first. let json_decision = if !stdout.trim().is_empty() { - serde_json::from_str::(stdout.trim()).ok() + serde_json::from_str::(stdout.trim()).ok() } else { None }; - // If we have valid JSON with a deny, prefer that over exit code. - if let Some(ref output) = json_decision { - if output.decision == "deny" { - let reason = output - .reason - .clone() - .unwrap_or_else(|| format!("denied by hook '{hook_name}'")); - - if exit_code != DENY_EXIT_CODE && exit_code != 0 { - tracing::warn!( - hook_name, - exit_code, - "JSON decision is 'deny' but exit code is not 0 or 2 — using JSON decision" + if let Some(output) = json_decision { + match gate_json_to_decision(output, hook_name) { + Ok(HookDecision::Deny { reason, hook_name }) => { + // A JSON deny is honored on any exit code (fail-safe). + if exit_code != GATE_EXIT_CODE && exit_code != 0 { + tracing::warn!( + hook_name, + exit_code, + "JSON decision is 'deny' but exit code is not 0 or 2 — using JSON decision" + ); + } + return ( + HookRunnerResult::Decision(HookDecision::Deny { reason, hook_name }), + elapsed, ); } - - return ( - HookRunnerResult::Decision(HookDecision::Deny { - reason, - hook_name: hook_name.to_string(), - }), - elapsed, - ); - } - - if output.decision == "allow" { - if exit_code == DENY_EXIT_CODE { - tracing::warn!( - hook_name, - "JSON decision is 'allow' but exit code is 2 — using JSON decision" - ); + Ok(HookDecision::Allow) => { + if exit_code == GATE_EXIT_CODE { + // Exit 2 wins over a JSON allow (stdout is not + // processed on exit 2); the exit-code ladder below + // denies. + tracing::warn!( + hook_name, + "JSON decision is 'allow' but exit code is 2 — denying (stdout is ignored on exit 2)" + ); + } else { + return (HookRunnerResult::Decision(HookDecision::Allow), elapsed); + } } - return (HookRunnerResult::Decision(HookDecision::Allow), elapsed); + // Unknown decision value: failure so typos surface. + Err(err) => return (HookRunnerResult::Failed(err), elapsed), } - - // Unknown decision value — treat as failure. - return ( - HookRunnerResult::Failed(format!( - "unknown decision value '{}' from hook '{hook_name}'", - output.decision - )), - elapsed, - ); } - // No valid JSON — fall back to exit code. match exit_code { 0 => (HookRunnerResult::Decision(HookDecision::Allow), elapsed), - DENY_EXIT_CODE => ( + GATE_EXIT_CODE => ( HookRunnerResult::Decision(HookDecision::Deny { - reason: format!("denied by hook '{hook_name}' (exit code {DENY_EXIT_CODE})"), + reason: format!("denied by hook '{hook_name}' (exit code {GATE_EXIT_CODE})"), hook_name: hook_name.to_string(), }), elapsed, @@ -512,6 +471,74 @@ fn parse_blocking_result( } } +/// Parse the result of a `Stop`/`SubagentStop` gate hook from stdout, stderr, +/// and exit code: +/// +/// A valid decision JSON on stdout wins over the exit code. The exit code +/// decides only when stdout carries no usable JSON. +/// +/// * **JSON stdout (any exit code)**: parsed as [`StopHookJson`]: +/// `decision: "block"` (+ `reason`), `continue: false` (+ `stopReason`), and +/// `hookSpecificOutput.additionalContext`. +/// * **no JSON + exit 0**: plain allow-stop. +/// * **no JSON + exit 2**: block, with stderr as the feedback fed to the model. +/// * **no JSON + any other exit code**: failure (callers fail open: the agent +/// stops normally). +fn parse_stop_result( + stdout: &str, + stderr: &str, + exit_code: i32, + hook_name: &str, + elapsed: Duration, +) -> (HookRunnerResult, Duration) { + let trimmed = stdout.trim(); + if !trimmed.is_empty() { + match serde_json::from_str::(trimmed) { + Ok(json) => { + return match stop_json_to_outcome(json, hook_name) { + Ok(outcome) => (HookRunnerResult::Stop(outcome), elapsed), + Err(err) => (HookRunnerResult::Failed(err), elapsed), + }; + } + Err(err) => { + // JSON-looking output that fails to parse is likely a broken + // decision; warn and fall back to the exit code. + if trimmed.starts_with('{') { + tracing::warn!( + hook_name, + error = %err, + "stop hook stdout looks like JSON but failed to parse; falling back to the exit code" + ); + } + } + } + } + match exit_code { + 0 => (HookRunnerResult::Stop(StopHookOutcome::default()), elapsed), + GATE_EXIT_CODE => { + let feedback = stderr.trim(); + let block_reason = if feedback.is_empty() { + format!("Blocked by stop hook '{hook_name}' (exit code {GATE_EXIT_CODE})") + } else { + feedback.to_string() + }; + ( + HookRunnerResult::Stop(StopHookOutcome { + block_reason: Some(block_reason), + ..Default::default() + }), + elapsed, + ) + } + _ => ( + HookRunnerResult::Failed(format!( + "hook '{hook_name}' failed with exit code {exit_code}" + )), + elapsed, + ), + } +} + /// Truncate output bytes to MAX_OUTPUT_BYTES and convert to a lossy UTF-8 string. fn truncate_output(bytes: &[u8]) -> String { if bytes.len() <= MAX_OUTPUT_BYTES { @@ -545,179 +572,292 @@ mod tests { use super::*; #[test] - fn parse_allow_json() { - let (result, _) = + fn parse_json_decision() { + let (allow, _) = parse_blocking_result(r#"{"decision":"allow"}"#, 0, "test", Duration::ZERO); assert!(matches!( - result, + allow, HookRunnerResult::Decision(HookDecision::Allow) )); - } - #[test] - fn parse_deny_json() { - let (result, _) = parse_blocking_result( + let (deny, _) = parse_blocking_result( r#"{"decision":"deny","reason":"bad command"}"#, 2, "test", Duration::ZERO, ); - match result { + match deny { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert_eq!(reason, "bad command"); } other => panic!("expected Deny, got {other:?}"), } - } - #[test] - fn parse_deny_json_without_reason() { - let (result, _) = + let (deny_no_reason, _) = parse_blocking_result(r#"{"decision":"deny"}"#, 2, "my-hook", Duration::ZERO); - match result { + match deny_no_reason { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert!(reason.contains("my-hook")); } other => panic!("expected Deny, got {other:?}"), } + + let (unknown, _) = + parse_blocking_result(r#"{"decision":"maybe"}"#, 0, "test", Duration::ZERO); + assert!(matches!(unknown, HookRunnerResult::Failed(_))); } #[test] - fn fallback_to_exit_code_zero() { - let (result, _) = parse_blocking_result("", 0, "test", Duration::ZERO); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); + fn fallback_to_exit_code() { + for (stdout, code, expect_allow) in + [("", 0, true), ("not json at all", 0, true), ("", 2, false)] + { + let (result, _) = parse_blocking_result(stdout, code, "test", Duration::ZERO); + if expect_allow { + assert!(matches!( + result, + HookRunnerResult::Decision(HookDecision::Allow) + )); + } else { + assert!(matches!( + result, + HookRunnerResult::Decision(HookDecision::Deny { .. }) + )); + } + } + let (fail, _) = parse_blocking_result("", 1, "test", Duration::ZERO); + assert!(matches!(fail, HookRunnerResult::Failed(_))); } #[test] - fn fallback_to_exit_code_deny() { - let (result, _) = parse_blocking_result("", 2, "test", Duration::ZERO); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Deny { .. }) - )); - } - - #[test] - fn fallback_to_exit_code_failure() { - let (result, _) = parse_blocking_result("", 1, "test", Duration::ZERO); - assert!(matches!(result, HookRunnerResult::Failed(_))); - } - - #[test] - fn json_deny_overrides_exit_code_zero() { - // JSON says deny, exit code says success — prefer JSON deny. - let (result, _) = parse_blocking_result( + fn json_decision_vs_exit_code() { + let (deny, _) = parse_blocking_result( r#"{"decision":"deny","reason":"nope"}"#, 0, "test", Duration::ZERO, ); assert!(matches!( - result, + deny, + HookRunnerResult::Decision(HookDecision::Deny { .. }) + )); + + let (blocked, _) = + parse_blocking_result(r#"{"decision":"allow"}"#, 2, "test", Duration::ZERO); + assert!(matches!( + blocked, HookRunnerResult::Decision(HookDecision::Deny { .. }) )); } - #[test] - fn invalid_json_falls_back_to_exit_code() { - let (result, _) = parse_blocking_result("not json at all", 0, "test", Duration::ZERO); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); + fn stop_outcome(result: HookRunnerResult) -> StopHookOutcome { + match result { + HookRunnerResult::Stop(outcome) => outcome, + other => panic!("expected Stop outcome, got {other:?}"), + } } #[test] - fn unknown_decision_value() { - let (result, _) = - parse_blocking_result(r#"{"decision":"maybe"}"#, 0, "test", Duration::ZERO); - assert!(matches!(result, HookRunnerResult::Failed(_))); - } - - #[test] - fn truncate_small_output() { - let small = "hello world".as_bytes(); - let result = truncate_output(small); - assert_eq!(result, "hello world"); - } - - #[test] - fn truncate_large_output() { - let large = vec![b'x'; MAX_OUTPUT_BYTES + 1000]; - let result = truncate_output(&large); - assert!(result.ends_with(" [truncated]")); - assert!(result.len() > MAX_OUTPUT_BYTES); // marker appended - } - - #[test] - fn resolve_absolute_path() { - let spec = HookSpec { - name: "test".into(), - event: crate::event::HookEventName::PreToolUse, - handler_type: "command".into(), - configured_matcher: None, - matcher: None, - enabled: true, - command: Some(std::path::PathBuf::from("/usr/bin/hook")), - command_raw: Some("/usr/bin/hook".to_string()), - url: None, - url_raw: None, - timeout_ms: 5000, - source_dir: std::path::PathBuf::from("/some/dir"), - extra_env: std::collections::HashMap::new(), - }; + fn stop_block_decision_with_reason() { + let (result, _) = parse_stop_result( + r#"{"decision":"block","reason":"tests are failing"}"#, + "", + 0, + "my-stop", + Duration::ZERO, + ); + let outcome = stop_outcome(result); assert_eq!( - resolve_command_path(&spec), - Some(std::path::PathBuf::from("/usr/bin/hook")) + outcome, + StopHookOutcome { + block_reason: Some("tests are failing".into()), + ..Default::default() + } + ); + + let (result, _) = + parse_stop_result(r#"{"decision":"block"}"#, "", 0, "my-stop", Duration::ZERO); + assert_eq!( + stop_outcome(result).block_reason.as_deref(), + Some("Blocked by stop hook 'my-stop'") ); } #[test] - fn resolve_relative_path() { - let spec = HookSpec { - name: "test".into(), - event: crate::event::HookEventName::PreToolUse, - handler_type: "command".into(), - configured_matcher: None, - matcher: None, - enabled: true, - command: Some(std::path::PathBuf::from("bin/check.sh")), - command_raw: Some("bin/check.sh".to_string()), - url: None, - url_raw: None, - timeout_ms: 5000, - source_dir: std::path::PathBuf::from("/project/.grok/hooks"), - extra_env: std::collections::HashMap::new(), - }; + fn stop_exit_2_blocks_with_stderr() { + let (result, _) = + parse_stop_result("", "run the test suite first\n", 2, "s", Duration::ZERO); assert_eq!( - resolve_command_path(&spec), + stop_outcome(result).block_reason.as_deref(), + Some("run the test suite first") + ); + + let (result, _) = parse_stop_result("", "", 2, "s", Duration::ZERO); + assert_eq!( + stop_outcome(result).block_reason.as_deref(), + Some("Blocked by stop hook 's' (exit code 2)") + ); + } + + #[test] + fn stop_stdout_json_wins_over_exit_2() { + let (result, _) = parse_stop_result( + r#"{"continue":false,"stopReason":"enough","hookSpecificOutput":{"additionalContext":"ctx"}}"#, + "log noise\n", + 2, + "s", + Duration::ZERO, + ); + let outcome = stop_outcome(result); + assert_eq!( + outcome + .force_stop + .as_ref() + .and_then(|f| f.reason.as_deref()), + Some("enough") + ); + assert_eq!(outcome.additional_context.as_deref(), Some("ctx")); + + let (result, _) = parse_stop_result("log noise\n", "blocked", 2, "s", Duration::ZERO); + assert_eq!( + stop_outcome(result).block_reason.as_deref(), + Some("blocked") + ); + } + + #[test] + fn stop_continue_false_prevents_continuation() { + let (result, _) = parse_stop_result( + r#"{"continue":false,"stopReason":"budget exhausted"}"#, + "", + 0, + "s", + Duration::ZERO, + ); + let outcome = stop_outcome(result); + assert_eq!( + outcome, + StopHookOutcome { + force_stop: Some(crate::result::StopOverride { + reason: Some("budget exhausted".into()), + }), + ..Default::default() + } + ); + let (result, _) = parse_stop_result(r#"{"continue":true}"#, "", 0, "s", Duration::ZERO); + assert!(stop_outcome(result).is_empty()); + } + + #[test] + fn stop_additional_context_captured() { + let (result, _) = parse_stop_result( + r#"{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"run the test suite before finishing"}}"#, + "", + 0, + "s", + Duration::ZERO, + ); + let outcome = stop_outcome(result); + assert_eq!( + outcome, + StopHookOutcome { + additional_context: Some("run the test suite before finishing".into()), + ..Default::default() + } + ); + } + + #[test] + fn stop_allow_failure_and_unknown_decision() { + let (result, _) = parse_stop_result("", "", 0, "s", Duration::ZERO); + assert!(stop_outcome(result).is_empty()); + + let (result, _) = parse_stop_result("all done!", "", 0, "s", Duration::ZERO); + assert!(stop_outcome(result).is_empty()); + + let (result, _) = parse_stop_result("", "boom", 1, "s", Duration::ZERO); + assert!(matches!(result, HookRunnerResult::Failed(_))); + + let (result, _) = parse_stop_result(r#"{"decision":"deny"}"#, "", 0, "s", Duration::ZERO); + assert!(matches!(result, HookRunnerResult::Failed(_))); + + // `approve` is accepted as a no-op (shared approve/block vocabulary). + let (result, _) = + parse_stop_result(r#"{"decision":"approve"}"#, "", 0, "s", Duration::ZERO); + assert!(stop_outcome(result).is_empty()); + } + + #[test] + fn stop_output_captures_all_combined_signals() { + let (result, _) = parse_stop_result( + r#"{"decision":"block","reason":"keep going","continue":false,"stopReason":"user said stop","hookSpecificOutput":{"additionalContext":"ctx"}}"#, + "", + 0, + "s", + Duration::ZERO, + ); + let outcome = stop_outcome(result); + assert_eq!( + outcome, + StopHookOutcome { + block_reason: Some("keep going".into()), + additional_context: Some("ctx".into()), + force_stop: Some(crate::result::StopOverride { + reason: Some("user said stop".into()), + }), + } + ); + } + + #[test] + fn truncate_output_respects_limit() { + assert_eq!(truncate_output(b"hello world"), "hello world"); + + let large = truncate_output(&vec![b'x'; MAX_OUTPUT_BYTES + 1000]); + assert!(large.ends_with(" [truncated]")); + } + + #[test] + fn resolve_command_path_variants() { + let spec = + |handler: crate::config::HandlerType, command: Option<&str>, source: &str| HookSpec { + name: "test".into(), + event: crate::event::HookEventName::PreToolUse, + handler_type: handler, + configured_matcher: None, + matcher: None, + enabled: true, + command: command.map(std::path::PathBuf::from), + command_raw: command.map(str::to_string), + url: None, + url_raw: None, + timeout_ms: 5000, + source_dir: std::path::PathBuf::from(source), + extra_env: std::collections::HashMap::new(), + }; + use crate::config::HandlerType; + assert_eq!( + resolve_command_path(&spec( + HandlerType::Command, + Some("/usr/bin/hook"), + "/some/dir" + )), + Some(std::path::PathBuf::from("/usr/bin/hook")) + ); + assert_eq!( + resolve_command_path(&spec( + HandlerType::Command, + Some("bin/check.sh"), + "/project/.grok/hooks" + )), Some(std::path::PathBuf::from( "/project/.grok/hooks/bin/check.sh" )) ); - } - - #[test] - fn resolve_no_command_for_http() { - let spec = HookSpec { - name: "test".into(), - event: crate::event::HookEventName::PreToolUse, - handler_type: "http".into(), - configured_matcher: None, - matcher: None, - enabled: true, - command: None, - command_raw: None, - url: Some("https://hooks.example.com/check".into()), - url_raw: Some("https://hooks.example.com/check".into()), - timeout_ms: 5000, - source_dir: std::path::PathBuf::from("/project"), - extra_env: std::collections::HashMap::new(), - }; - assert_eq!(resolve_command_path(&spec), None); + assert_eq!( + resolve_command_path(&spec(HandlerType::Http, None, "/project")), + None + ); } /// Helper to build a HookSpec that runs a shell command. @@ -725,7 +865,7 @@ mod tests { HookSpec { name: "test-hook".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -750,8 +890,13 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::Stop { reason: "test".into(), + stop_hook_active: false, + last_assistant_message: None, + background_tasks: None, + session_crons: None, }, } } @@ -763,6 +908,45 @@ mod tests { } } + #[tokio::test] + #[cfg(unix)] + async fn hook_times_out() { + let mut spec = make_shell_spec("sleep 5"); + spec.timeout_ms = 100; + let envelope = make_envelope(); + let ctx = make_ctx(); + let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; + assert!( + matches!(&result, HookRunnerResult::Failed(msg) if msg.contains("timed out")), + "expected a timeout failure, got {result:?}" + ); + } + + /// Regression: a hook that never reads stdin while writing large stdout must + /// not deadlock, since stdin is written concurrently with draining output. + #[tokio::test] + #[cfg(unix)] + async fn large_envelope_with_unreading_hook_does_not_deadlock() { + use crate::event::HookPayload; + let spec = make_shell_spec("head -c 200000 /dev/zero | tr '\\0' x"); + let mut envelope = make_envelope(); + envelope.payload = HookPayload::Stop { + reason: "test".into(), + stop_hook_active: false, + // Larger than the OS pipe buffer (~64 KB) so the stdin write blocks + // without concurrent draining. + last_assistant_message: Some("x".repeat(256 * 1024)), + background_tasks: None, + session_crons: None, + }; + let ctx = make_ctx(); + let run = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe); + let (result, _) = tokio::time::timeout(std::time::Duration::from_secs(10), run) + .await + .expect("hook must not deadlock on a large envelope"); + assert!(matches!(result, HookRunnerResult::Success)); + } + /// Verify that setsid() prevents hook child processes from opening /// `/dev/tty`. This is the core fix for GPG pinentry corruption. /// @@ -789,7 +973,7 @@ mod tests { let envelope = make_envelope(); let ctx = make_ctx(); - let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await; + let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; assert!( matches!(result, HookRunnerResult::Success), @@ -798,30 +982,13 @@ mod tests { ); } - /// Regression: hook commands still execute successfully. - #[tokio::test] - async fn test_hook_basic_execution() { - let spec = make_shell_spec("exit 0"); - let envelope = make_envelope(); - let ctx = make_ctx(); - - let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await; - - assert!( - matches!(result, HookRunnerResult::Success), - "hook should succeed, got {:?}", - result - ); - } - - /// Regression: blocking hooks still parse JSON decisions correctly. #[tokio::test] async fn test_hook_blocking_allow() { let spec = make_shell_spec(r#"echo '{"decision":"allow"}'"#); let envelope = make_envelope(); let ctx = make_ctx(); - let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, true).await; + let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, GateKind::Tool).await; assert!( matches!(result, HookRunnerResult::Decision(HookDecision::Allow)), @@ -830,33 +997,6 @@ mod tests { ); } - #[test] - fn shell_command_detection() { - // Commands with shell metacharacters should be detected. - assert!("echo hello".contains(' ')); // space - assert!("a || b".contains('|')); // pipe/or - assert!("a && b".contains('&')); // and - assert!("a; b".contains(';')); // semicolon - assert!("a > out".contains('>')); // redirect - // Env-var interpolation must also force the sh -c branch so that - // commands like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` get expanded - // by the shell rather than treated as a literal executable path. - assert!("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh".contains('$')); - assert!("$HOME/bin/hook".contains('$')); - - // Tilde at the start must also force the sh -c branch so that - // `~/.claude/hook.sh` gets expanded to the user's home directory - // rather than being joined to source_dir as a literal path. - assert!("~/.claude/hook.sh".starts_with('~')); - assert!("~/bin/hook".starts_with('~')); - - // Simple executable paths should not be detected. - assert!(!"bin/check.sh".contains(' ')); - assert!(!"/usr/bin/hook".contains(' ')); - assert!(!"bin/check.sh".contains('$')); - assert!(!"bin/check.sh".starts_with('~')); - } - /// Regression: a hook command that uses `${VAR}` interpolation /// without any other shell metacharacters must still be invoked via /// `sh -c` so that the env var supplied via `extra_env` is expanded. @@ -887,7 +1027,7 @@ mod tests { let spec = HookSpec { name: "test-env-interp".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -902,7 +1042,7 @@ mod tests { let envelope = make_envelope(); let ctx = make_ctx(); - let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; + let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; assert!( matches!(result, HookRunnerResult::Success), @@ -911,7 +1051,7 @@ mod tests { ); } - /// `CLAUDE_PROJECT_DIR` is part of the external hook contract -- it points + /// `CLAUDE_PROJECT_DIR` is part of the external hook contract: it points /// to the workspace/project root and is set for ALL hooks (not just /// plugin-scoped ones). Plugin hooks frequently reference it as /// `"$CLAUDE_PROJECT_DIR/.claude/hooks/foo.sh"`. The runner must export @@ -943,7 +1083,7 @@ mod tests { let spec = HookSpec { name: "test-claude-project-dir".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -963,7 +1103,7 @@ mod tests { session_id: "test-session", workspace_root: &workspace, }; - let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; + let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; assert!( matches!(result, HookRunnerResult::Success), @@ -972,63 +1112,47 @@ mod tests { ); } - /// Unit tests for the `find_unresolved_env_vars` parser. We seed - /// `extra_env` to control what's "set" without depending on the test - /// process's real environment (other than excluding common vars). + /// `extra_env` seeds what's "set" so the test does not depend on the + /// process environment. #[test] - fn find_unresolved_braced_form() { + fn find_unresolved_detects_and_dedups() { let mut env = std::collections::HashMap::new(); env.insert("KNOWN".to_string(), "x".to_string()); - let v = find_unresolved_env_vars("${KNOWN}/${SOME_GB1183_UNSET_VAR}/foo", &env); - assert_eq!(v, vec!["SOME_GB1183_UNSET_VAR".to_string()]); - } - - #[test] - fn find_unresolved_bare_form() { - let env = std::collections::HashMap::new(); - let v = find_unresolved_env_vars("$SOME_GB1183_BARE_UNSET/foo", &env); - assert_eq!(v, vec!["SOME_GB1183_BARE_UNSET".to_string()]); - } - - #[test] - fn find_unresolved_skips_runner_vars() { - let env = std::collections::HashMap::new(); - let v = find_unresolved_env_vars( - "${GROK_HOOK_EVENT}/${CLAUDE_PROJECT_DIR}/${GROK_SESSION_ID}/foo", - &env, + assert_eq!( + find_unresolved_env_vars("${KNOWN}/${SOME_GB1183_UNSET_VAR}/foo", &env), + vec!["SOME_GB1183_UNSET_VAR".to_string()] ); - assert!( - v.is_empty(), - "runner-set vars should never be flagged, got {v:?}" + assert_eq!( + find_unresolved_env_vars("$SOME_GB1183_BARE_UNSET/foo", &env), + vec!["SOME_GB1183_BARE_UNSET".to_string()] + ); + assert_eq!( + find_unresolved_env_vars( + "${MISSING_GB1183_DUP} && ${MISSING_GB1183_DUP}/foo $MISSING_GB1183_DUP", + &env, + ), + vec!["MISSING_GB1183_DUP".to_string()] ); } #[test] - fn find_unresolved_skips_extra_env() { + fn find_unresolved_skips_resolvable_vars() { let mut env = std::collections::HashMap::new(); env.insert("CLAUDE_PLUGIN_ROOT".to_string(), "/plugins/foo".to_string()); - let v = find_unresolved_env_vars("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh", &env); - assert!( - v.is_empty(), - "vars present in extra_env should not be flagged, got {v:?}" - ); - } - - #[test] - fn find_unresolved_dedups() { - let env = std::collections::HashMap::new(); let v = find_unresolved_env_vars( - "${MISSING_GB1183_DUP} && ${MISSING_GB1183_DUP}/foo $MISSING_GB1183_DUP", + "${GROK_HOOK_EVENT}/${CLAUDE_PROJECT_DIR}/${GROK_SESSION_ID}/${CLAUDE_PLUGIN_ROOT}/foo", &env, ); - assert_eq!(v, vec!["MISSING_GB1183_DUP".to_string()]); + assert!( + v.is_empty(), + "resolvable vars should not be flagged, got {v:?}" + ); } #[test] fn find_unresolved_skips_non_var_dollars() { let env = std::collections::HashMap::new(); // $1 (positional), $$ (pid), $(...) (cmd subst), $? (exit code), $#. - // None of these are env vars; none should be flagged. let v = find_unresolved_env_vars("echo $1 $$ $? $# $(date)", &env); assert!( v.is_empty(), @@ -1037,38 +1161,16 @@ mod tests { } #[test] - fn find_unresolved_skips_locally_assigned_vars() { + fn find_unresolved_skips_local_assignments() { let env = std::collections::HashMap::new(); - // INPUT is set locally inside the same command. The check must - // recognize that and not flag the subsequent ${INPUT} references. - let cmd = r#"INPUT=$(cat); echo "$INPUT" | grep -q foo"#; - let v = find_unresolved_env_vars(cmd, &env); - assert!( - v.is_empty(), - "locally-assigned vars must not be flagged, got {v:?}" - ); - } - - #[test] - fn find_unresolved_skips_read_assigned_vars() { - let env = std::collections::HashMap::new(); - let cmd = "read -r LINE; echo $LINE"; - let v = find_unresolved_env_vars(cmd, &env); - assert!( - v.is_empty(), - "vars assigned via `read` must not be flagged, got {v:?}" - ); - } - - #[test] - fn find_unresolved_skips_assignments_after_separators() { - let env = std::collections::HashMap::new(); - let cmd = "echo first; X=hello && echo $X | cat"; - let v = find_unresolved_env_vars(cmd, &env); - assert!( - v.is_empty(), - "assignment after `;` should be recognized, got {v:?}" - ); + for cmd in [ + r#"INPUT=$(cat); echo "$INPUT" | grep -q foo"#, + "read -r LINE; echo $LINE", + "echo first; X=hello && echo $X | cat", + ] { + let v = find_unresolved_env_vars(cmd, &env); + assert!(v.is_empty(), "`{cmd}` should not flag any var, got {v:?}"); + } } #[test] @@ -1110,7 +1212,7 @@ mod tests { let spec = HookSpec { name: "test-undef".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -1127,7 +1229,7 @@ mod tests { let envelope = make_envelope(); let ctx = make_ctx(); - let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; + let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; match result { HookRunnerResult::Failed(reason) => { @@ -1183,7 +1285,7 @@ mod tests { let spec = HookSpec { name: "test-tilde".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -1207,12 +1309,16 @@ mod tests { // its child inherits it. Retry ONLY that exact transient; a real tilde- // routing break surfaces as a different result (127/spawn error), so the // assertion below keeps its diagnostic power. - let mut result = run_command_hook(&spec, &envelope, &ctx, false).await.0; + let mut result = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe) + .await + .0; for _ in 0..8 { if !matches!(&result, HookRunnerResult::Failed(msg) if msg == "exit code 126") { break; } - result = run_command_hook(&spec, &envelope, &ctx, false).await.0; + result = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe) + .await + .0; } assert!( @@ -1223,7 +1329,7 @@ mod tests { } /// Hooks that explicitly handle the unset case via parameter expansion - /// (e.g. `${VAR:-/some/default}`) must NOT be refused -- the user has + /// (e.g. `${VAR:-/some/default}`) must NOT be refused: the user has /// expressed intent for what should happen when the var is unset. #[tokio::test] async fn test_parameter_expansion_default_is_not_refused() { @@ -1241,7 +1347,7 @@ mod tests { let spec = HookSpec { name: "test-default".into(), event: crate::event::HookEventName::Stop, - handler_type: "command".into(), + handler_type: crate::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -1261,7 +1367,7 @@ mod tests { let envelope = make_envelope(); let ctx = make_ctx(); - let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; + let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await; assert!( matches!(result, HookRunnerResult::Success), diff --git a/crates/codegen/xai-grok-hooks/src/runner/http.rs b/crates/codegen/xai-grok-hooks/src/runner/http.rs index f003ac0..88cae62 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/http.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/http.rs @@ -6,36 +6,27 @@ use std::net::IpAddr; use std::time::{Duration, Instant}; -use serde::Deserialize; use url::Url; use crate::config::HookSpec; use crate::event::HookEventEnvelope; -use crate::result::{HookDecision, HttpInfo}; +use crate::result::{HookDecision, HttpInfo, StopHookOutcome}; -use super::{HookRunOutput, HookRunnerResult, RunContext}; +use super::{ + GateKind, HookRunOutput, HookRunnerResult, RunContext, StopHookJson, stop_json_to_outcome, +}; -/// Maximum characters to keep from the response body for the preview. const RESPONSE_PREVIEW_MAX: usize = 200; -/// The JSON result structure expected from blocking HTTP hooks. -#[derive(Debug, Deserialize)] -struct HttpHookOutput { - decision: String, - #[serde(default)] - reason: Option, -} - -/// CWE-918: Returns `true` if an IP address is in a private, link-local, -/// or cloud metadata range that should be blocked to prevent SSRF attacks. -/// -/// Loopback (`127.x` / `::1`) is allowed for local development servers. +/// CWE-918: `true` if `ip` is in a private, link-local, or cloud metadata range +/// that must be blocked to prevent SSRF. Loopback (`127.x`/`::1`) is allowed for +/// local development servers. fn is_blocked_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => { let octets = v4.octets(); if octets[0] == 127 { - return false; // loopback — allowed for local dev + return false; // loopback, allowed for local dev } if octets[0] == 10 { return true; // RFC 1918: 10.0.0.0/8 @@ -59,7 +50,7 @@ fn is_blocked_ip(ip: &IpAddr) -> bool { } IpAddr::V6(v6) => { if v6.is_loopback() { - return false; // ::1 — allowed for local dev + return false; // ::1, allowed for local dev } if v6.is_unspecified() { return true; // :: @@ -69,25 +60,22 @@ fn is_blocked_ip(ip: &IpAddr) -> bool { } let segments = v6.segments(); if segments[0] & 0xffc0 == 0xfe80 { - return true; // fe80::/10 — link-local + return true; // fe80::/10 link-local } if segments[0] & 0xfe00 == 0xfc00 { - return true; // fc00::/7 — unique local (ULA) + return true; // fc00::/7 unique local (ULA) } false } } } -/// CWE-918: Validate a hook URL to prevent SSRF. -/// -/// Requirements: -/// - Only HTTPS scheme is allowed (reject HTTP / other schemes). -/// - Resolved IP addresses must not be in private/link-local/metadata ranges. +/// CWE-918: prevent SSRF. Only HTTPS is allowed and resolved IPs must not be +/// private/link-local/metadata. Known gap: the request re-resolves the host, so +/// a rebinding DNS server can still swap in a blocked IP after this check. async fn validate_hook_url(url: &str) -> Result<(), String> { let parsed = Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?; - // Restrict to HTTPS only. if parsed.scheme() != "https" { return Err(format!( "only https:// URLs are allowed for HTTP hooks, got {}://", @@ -99,7 +87,6 @@ async fn validate_hook_url(url: &str) -> Result<(), String> { .host_str() .ok_or_else(|| "URL has no host".to_string())?; - // If host is a literal IP, check it directly. if let Ok(ip) = host.parse::() { if is_blocked_ip(&ip) { return Err(format!("URL resolves to blocked private/internal IP: {ip}")); @@ -107,7 +94,6 @@ async fn validate_hook_url(url: &str) -> Result<(), String> { return Ok(()); } - // DNS resolution check. let port = parsed.port_or_known_default().unwrap_or(443); let addr_str = format!("{host}:{port}"); let addrs: Vec = tokio::net::lookup_host(&addr_str) @@ -131,29 +117,25 @@ async fn validate_hook_url(url: &str) -> Result<(), String> { Ok(()) } -/// Build the reqwest client used to send a hook request. fn build_hook_client(timeout_ms: u64) -> reqwest::Client { reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) // `validate_hook_url` only vets the initial URL, not redirect targets. .redirect(reqwest::redirect::Policy::none()) .build() - .unwrap_or_default() + // A default fallback would follow redirects and drop the timeout, + // reopening the SSRF path; build only fails on a TLS init fault. + .expect("hook HTTP client config is valid") } -/// Run a single HTTP hook. -/// -/// POSTs the serialized `HookEventEnvelope` as JSON to `spec.url`. -/// For blocking hooks (`PreToolUse`), parses the response JSON for -/// `{"decision": "allow"}` or `{"decision": "deny", "reason": "..."}`. -/// For non-blocking hooks, any 2xx response is success. -/// -/// Respects `spec.timeout_ms` for the entire request. +/// POST the serialized `HookEventEnvelope` to `spec.url` and parse the response +/// per gate mode (blocking parses a decision JSON; observe treats any 2xx as +/// success). pub async fn run_http_hook( spec: &HookSpec, envelope: &HookEventEnvelope, _ctx: &RunContext<'_>, - is_blocking: bool, + mode: GateKind, ) -> HookRunOutput { let start = Instant::now(); @@ -165,39 +147,17 @@ pub async fn run_http_hook( ); }; - // Expand `${VAR}` / `$VAR` in the URL right before validation. We - // re-run expansion here (in addition to the load-time pass in - // `parse_hook_file`) because plugin URLs can reference plugin-injected - // vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) that only land in - // `spec.extra_env` after the plugin adapter wires them in. - // - // For plugin hooks specifically: the load-time pass in - // `parse_hook_file` runs BEFORE the plugin adapter populates - // `extra_env` with plugin keys, so `${CLAUDE_PLUGIN_ROOT}` etc. - // survive that pass and are resolved here at runtime. For - // non-plugin hooks the load-time pass already resolved everything - // resolvable, and this pass is effectively a no-op. - // - // Unset refs are preserved verbatim, so `validate_hook_url` will - // reject them with an "invalid URL" error rather than silently - // smuggling a literal `${VAR}` past validation. + // Re-expand the URL here (in addition to the load-time pass) because plugin + // vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) only land in `extra_env` after + // the plugin adapter runs. Unset refs are preserved so `validate_hook_url` + // rejects them rather than smuggling a literal `${VAR}` past validation. let expanded_url = crate::env_expand::expand_env_vars_with_extra(raw_url, &spec.extra_env); let url: &str = &expanded_url; - // For tracing/log purposes prefer the pre-expansion source so - // resolved values from the user `env` map (which may contain - // secrets like API tokens) don't land in `~/.grok/logs`. Falls - // back to the expanded form if the spec was constructed by a - // legacy path that didn't populate `url_raw`. The same `log_url` - // is also threaded into `format!("HTTP request failed for {}: - // {}", log_url, e.without_url())` below so reqwest's default - // `Display` (which appends the request URL) does not bypass the - // raw-source preference. + // Prefer the pre-expansion source for logs so resolved `env` secrets don't + // reach `~/.grok/logs`; threaded into the reqwest error format below so + // reqwest's default `Display` (which appends the URL) can't bypass it. let log_url: &str = spec.url_raw.as_deref().unwrap_or(url); - // Helper: build an `HttpInfo` populated with both the - // post-expansion `url` (for SSRF debugging) and the raw source - // form (for any user-facing display surface). See `HttpInfo` - // rustdoc on `crate::result::HttpInfo` for the contract. let make_info = |status: Option, preview: Option| -> HttpInfo { HttpInfo { url: url.to_owned(), @@ -207,8 +167,20 @@ pub async fn run_http_hook( } }; - // CWE-918: Validate URL before sending any data. - if let Err(reason) = validate_hook_url(url).await { + // CWE-918: validate before sending. Bound the DNS lookup by the hook + // timeout; the reqwest timeout only covers the request that follows. + let validation = tokio::time::timeout( + Duration::from_millis(spec.timeout_ms), + validate_hook_url(url), + ) + .await + .unwrap_or_else(|_| { + Err(format!( + "URL validation timed out after {}ms", + spec.timeout_ms + )) + }); + if let Err(reason) = validation { tracing::warn!( hook_name = %spec.name, url = %log_url, @@ -245,15 +217,10 @@ pub async fn run_http_hook( Ok(r) => r, Err(e) => { let elapsed = start.elapsed(); - // SECURITY: `reqwest::Error::Display` unconditionally - // appends the request URL. If the resolved URL embeds a - // secret resolved from the user `env` map (e.g. - // `?token=ghp_REAL_SECRET`), the secret would otherwise - // land in `HookRunResult::Failed.error` and surface in - // pager scrollback / wire DTOs. `e.without_url()` strips - // the URL from the formatted output so we substitute our - // own `log_url` (which prefers the raw source form) in - // its place. + // SECURITY: `reqwest::Error::Display` appends the request URL, which + // may embed an `env`-map secret and leak into `Failed.error` and + // pager scrollback. `e.without_url()` strips it so we substitute + // `log_url` (the raw source form). let error = if e.is_timeout() { format!("timed out after {}ms", spec.timeout_ms) } else { @@ -279,7 +246,7 @@ pub async fn run_http_hook( "http hook completed" ); - if !is_blocking { + if mode == GateKind::Observe { let http_info = Some(make_info(Some(status_code), None)); if status.is_success() { return (HookRunnerResult::Success, elapsed, http_info); @@ -291,13 +258,10 @@ pub async fn run_http_hook( ); } - // Blocking hook: parse response JSON for decision. let response_text = match response.text().await { Ok(t) => t, Err(e) => { - // SECURITY: same `without_url()` reasoning as the send - // failure above -- reqwest's body-read error also includes - // the URL by default. + // SECURITY: scrub the URL as in the send-failure branch above. return ( HookRunnerResult::Failed(format!( "failed to read response body for {}: {}", @@ -318,49 +282,65 @@ pub async fn run_http_hook( let http_info = Some(make_info(Some(status_code), response_preview.clone())); - let result = parse_http_blocking_result(&response_text, status, &spec.name); + let result = match mode { + GateKind::Tool => parse_http_blocking_result(&response_text, status, &spec.name), + GateKind::Stop => parse_http_stop_result(&response_text, status, &spec.name), + GateKind::Observe => HookRunnerResult::Success, + }; (result, elapsed, http_info) } -/// Parse an HTTP blocking hook response into a `HookRunnerResult`. -/// -/// This is the HTTP analogue of `command::parse_blocking_result`. -/// Extracted as a standalone function so it can be unit-tested without -/// making real HTTP requests. +/// HTTP analogue of `command::parse_stop_result`: a 2xx JSON body is parsed for +/// the decision; a 2xx empty/non-JSON body allows the stop; a non-2xx response +/// is a failure (callers fail open). +fn parse_http_stop_result( + response_text: &str, + status: reqwest::StatusCode, + hook_name: &str, +) -> HookRunnerResult { + if !status.is_success() { + return HookRunnerResult::Failed(format!("HTTP status {status}")); + } + let trimmed = response_text.trim(); + if trimmed.is_empty() { + return HookRunnerResult::Stop(StopHookOutcome::default()); + } + match serde_json::from_str::(trimmed) { + Ok(json) => match stop_json_to_outcome(json, hook_name) { + Ok(outcome) => HookRunnerResult::Stop(outcome), + Err(err) => HookRunnerResult::Failed(err), + }, + Err(e) => { + tracing::warn!( + hook_name = %hook_name, + error = %e, + "could not parse HTTP stop hook response JSON, treating as allow-stop" + ); + HookRunnerResult::Stop(StopHookOutcome::default()) + } + } +} + +/// Parse an HTTP blocking hook response, the analogue of +/// `command::parse_blocking_result`. fn parse_http_blocking_result( response_text: &str, status: reqwest::StatusCode, hook_name: &str, ) -> HookRunnerResult { if response_text.trim().is_empty() { - // No body: use HTTP status as fallback. if status.is_success() { return HookRunnerResult::Decision(HookDecision::Allow); } return HookRunnerResult::Failed(format!("HTTP status {} with empty body", status)); } - match serde_json::from_str::(response_text) { - Ok(output) => { - if output.decision == "deny" { - let reason = output - .reason - .unwrap_or_else(|| format!("denied by hook '{}'", hook_name)); - HookRunnerResult::Decision(HookDecision::Deny { - reason, - hook_name: hook_name.to_string(), - }) - } else if output.decision == "allow" { - HookRunnerResult::Decision(HookDecision::Allow) - } else { - HookRunnerResult::Failed(format!( - "unknown decision value '{}' from hook '{}'", - output.decision, hook_name - )) - } - } + match serde_json::from_str::(response_text) { + Ok(output) => match super::gate_json_to_decision(output, hook_name) { + Ok(decision) => HookRunnerResult::Decision(decision), + Err(err) => HookRunnerResult::Failed(err), + }, Err(e) => { - // Cannot parse response: fail-open if status is success. if status.is_success() { tracing::warn!( hook_name = %hook_name, @@ -378,16 +358,13 @@ fn parse_http_blocking_result( } } -/// Truncate a response body string for preview display. -/// -/// Uses `char_indices` to find a safe UTF-8 boundary so we never panic -/// on multi-byte characters. +/// Truncate a response body for preview, cutting on a UTF-8 char boundary so +/// multi-byte characters never panic. fn truncate_preview(s: &str) -> String { let trimmed = s.trim(); if trimmed.len() <= RESPONSE_PREVIEW_MAX { trimmed.to_string() } else { - // Find the last char boundary at or before RESPONSE_PREVIEW_MAX bytes. let boundary = trimmed .char_indices() .take_while(|&(i, _)| i <= RESPONSE_PREVIEW_MAX) @@ -405,8 +382,6 @@ mod tests { use super::*; use reqwest::StatusCode; - // ── parse_http_blocking_result tests ────────────────────────── - #[test] fn http_allow_json() { let result = @@ -461,22 +436,51 @@ mod tests { } } + /// The JSON vocabulary itself is covered by the shared `stop_json_to_outcome` + /// tests; this covers only the HTTP status/body handling. #[test] - fn http_empty_body_success_allows() { - let result = parse_http_blocking_result("", StatusCode::OK, "test-hook"); + fn http_stop_status_and_body_handling() { + match parse_http_stop_result( + r#"{"decision":"block","reason":"tests failing"}"#, + StatusCode::OK, + "s", + ) { + HookRunnerResult::Stop(o) => { + assert_eq!(o.block_reason.as_deref(), Some("tests failing")); + } + other => panic!("expected Stop, got {other:?}"), + } + match parse_http_stop_result("", StatusCode::OK, "s") { + HookRunnerResult::Stop(o) => assert!(o.is_empty()), + other => panic!("expected Stop, got {other:?}"), + } + match parse_http_stop_result("not json", StatusCode::OK, "s") { + HookRunnerResult::Stop(o) => assert!(o.is_empty()), + other => panic!("expected Stop, got {other:?}"), + } assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) + parse_http_stop_result(r#"{"decision":"deny"}"#, StatusCode::OK, "s"), + HookRunnerResult::Failed(_) + )); + assert!(matches!( + parse_http_stop_result( + r#"{"decision":"block"}"#, + StatusCode::INTERNAL_SERVER_ERROR, + "s" + ), + HookRunnerResult::Failed(_) )); } #[test] - fn http_empty_body_whitespace_success_allows() { - let result = parse_http_blocking_result(" \n ", StatusCode::OK, "test-hook"); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); + fn http_empty_body_success_allows() { + for body in ["", " \n "] { + let result = parse_http_blocking_result(body, StatusCode::OK, "test-hook"); + assert!(matches!( + result, + HookRunnerResult::Decision(HookDecision::Allow) + )); + } } #[test] @@ -493,17 +497,17 @@ mod tests { #[test] fn http_invalid_json_success_status_fail_open() { - // Unparseable JSON with 200 OK should fail-open to allow. - let result = parse_http_blocking_result("not json at all", StatusCode::OK, "test-hook"); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); + for body in ["not json at all", r#"{"decision":"deny""#] { + let result = parse_http_blocking_result(body, StatusCode::OK, "test-hook"); + assert!(matches!( + result, + HookRunnerResult::Decision(HookDecision::Allow) + )); + } } #[test] fn http_invalid_json_error_status_fails() { - // Unparseable JSON with 500 should fail. let result = parse_http_blocking_result("not json", StatusCode::INTERNAL_SERVER_ERROR, "test-hook"); match result { @@ -530,143 +534,69 @@ mod tests { } #[test] - fn http_allow_with_non_success_status() { - let result = parse_http_blocking_result( - r#"{"decision":"allow"}"#, - StatusCode::BAD_REQUEST, - "test-hook", - ); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); - } - - #[test] - fn http_partial_json_success_fail_open() { - let result = - parse_http_blocking_result(r#"{"decision":"deny""#, StatusCode::OK, "test-hook"); - assert!(matches!( - result, - HookRunnerResult::Decision(HookDecision::Allow) - )); - } - - #[test] - fn http_extra_fields_tolerated() { - let result = parse_http_blocking_result( - r#"{"decision":"deny","reason":"nope","extra":"ignored","count":42}"#, - StatusCode::OK, - "test-hook", - ); - match result { - HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { - assert_eq!(reason, "nope"); - } - other => panic!("expected Deny, got {other:?}"), + fn ssrf_blocks_private_and_special_ranges() { + for ip in [ + "10.0.0.1", // RFC 1918 10.0.0.0/8 + "10.255.255.255", + "172.16.0.1", // RFC 1918 172.16.0.0/12 + "172.31.255.255", + "192.168.0.1", // RFC 1918 192.168.0.0/16 + "192.168.255.255", + "169.254.0.1", // link-local / metadata + "169.254.169.254", + "100.64.0.1", // CGNAT + "100.127.255.255", + "0.0.0.0", // unspecified + "::", + ] { + assert!(is_blocked_ip(&ip.parse().unwrap()), "{ip} must be blocked"); + } + // Just outside the blocked RFC 1918 / CGNAT ranges. + for ip in ["172.15.0.1", "172.32.0.1", "100.63.0.1"] { + assert!(!is_blocked_ip(&ip.parse().unwrap()), "{ip} must be allowed"); } } - // ── SSRF protection: is_blocked_ip tests ────────────── - #[test] - fn ssrf_blocks_rfc1918_10x() { - assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap())); + fn ssrf_allows_loopback_and_public() { + for ip in ["127.0.0.1", "::1", "1.1.1.1", "8.8.8.8"] { + assert!(!is_blocked_ip(&ip.parse().unwrap()), "{ip} must be allowed"); + } } #[test] - fn ssrf_blocks_rfc1918_172x() { - assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_rfc1918_192168() { - assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_link_local_metadata() { - assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_cgnat() { - assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_unspecified() { - assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap())); - assert!(is_blocked_ip(&"::".parse().unwrap())); - } - - #[test] - fn ssrf_allows_loopback() { - assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"::1".parse().unwrap())); - } - - #[test] - fn ssrf_allows_public_ips() { - assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap())); - assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_ipv6_link_local() { - assert!(is_blocked_ip(&"fe80::1".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_ipv6_unique_local() { - assert!(is_blocked_ip(&"fc00::1".parse().unwrap())); - assert!(is_blocked_ip(&"fd00::1".parse().unwrap())); - } - - #[test] - fn ssrf_blocks_ipv4_mapped_ipv6_private() { - assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::().unwrap())); - assert!(is_blocked_ip( - &"::ffff:192.168.1.1".parse::().unwrap() - )); - } - - // ── SSRF protection: validate_hook_url tests ────────── - - #[tokio::test] - async fn ssrf_rejects_http_scheme() { - let result = validate_hook_url("http://example.com/hook").await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("https://")); + fn ssrf_blocks_ipv6_ranges() { + for ip in [ + "fe80::1", // link-local + "fc00::1", // unique local (ULA) + "fd00::1", + "::ffff:10.0.0.1", // IPv4-mapped private + "::ffff:192.168.1.1", + ] { + assert!( + is_blocked_ip(&ip.parse::().unwrap()), + "{ip} must be blocked" + ); + } } #[tokio::test] - async fn ssrf_rejects_ftp_scheme() { - let result = validate_hook_url("ftp://example.com/hook").await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("https://")); + async fn ssrf_rejects_non_https_schemes() { + for url in ["http://example.com/hook", "ftp://example.com/hook"] { + let err = validate_hook_url(url).await.expect_err("must reject"); + assert!(err.contains("https://")); + } } #[tokio::test] - async fn ssrf_rejects_private_ip_literal() { - let result = validate_hook_url("https://10.0.0.1/hook").await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("blocked")); - } - - #[tokio::test] - async fn ssrf_rejects_metadata_ip_literal() { - let result = validate_hook_url("https://169.254.169.254/latest/meta-data/").await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("blocked")); + async fn ssrf_rejects_blocked_ip_literals() { + for url in [ + "https://10.0.0.1/hook", + "https://169.254.169.254/latest/meta-data/", + ] { + let err = validate_hook_url(url).await.expect_err("must reject"); + assert!(err.contains("blocked")); + } } #[tokio::test] @@ -682,76 +612,23 @@ mod tests { assert!(result.unwrap_err().contains("invalid URL")); } - // ── URL env-var expansion (extra_env precedence) ─────────── + /// A host that never resolves fails validation (covers the DNS branch that + /// the literal-IP tests skip). `.invalid` is reserved to never resolve. + #[tokio::test] + async fn ssrf_rejects_unresolvable_host() { + let err = validate_hook_url("https://nonexistent.invalid/hook") + .await + .expect_err("unresolvable host must fail validation"); + assert!(err.contains("DNS resolution failed"), "got: {err}"); + } use crate::config::HookSpec; use crate::event::{HookEventEnvelope, HookEventName, HookPayload}; use crate::test_support::with_env_var; - /// Regression: an HTTP hook whose `url` references a var present only - /// in `spec.extra_env` (not the process env) must still be expanded - /// at runtime by `run_http_hook`. This is the path used by plugin - /// hooks where the plugin adapter wires `${CLAUDE_PLUGIN_ROOT}` into - /// `extra_env` after the load-time pass in `parse_hook_file` ran. - /// - /// Documentation-of-intent unit test for the helper. The end-to-end - /// proof through `run_http_hook` lives in - /// [`run_http_hook_uses_post_expansion_url_for_ssrf`] below. - #[test] - fn url_extra_env_takes_precedence_in_runtime_expansion() { - // Use the same helper the runtime path uses; we don't need to - // make a real network call to verify the substitution, only that - // the helper resolves the right value from extra_env. - let mut extra = std::collections::HashMap::new(); - extra.insert("PLUGIN_HOST".to_string(), "example.com".to_string()); - let out = - crate::env_expand::expand_env_vars_with_extra("https://${PLUGIN_HOST}/check", &extra); - assert_eq!(out, "https://example.com/check"); - } - - /// If `extra_env` shadows a process-env var with the same name, the - /// `extra_env` value wins. This matches the contract documented on - /// `HookSpec::extra_env` and matches the lookup order in - /// `runner/command.rs`'s pre-flight check. Documentation-of-intent - /// unit test (the end-to-end variant via `run_http_hook` lives in - /// `tests/integration.rs`). - #[test] - fn url_extra_env_shadows_process_env() { - let key = "GROK_HOOKS_HTTP_TEST_SHADOW"; - with_env_var(key, Some("from-process"), || { - let mut extra = std::collections::HashMap::new(); - extra.insert(key.to_string(), "from-extra".to_string()); - let out = crate::env_expand::expand_env_vars_with_extra( - &format!("https://${{{key}}}/x"), - &extra, - ); - assert_eq!(out, "https://from-extra/x"); - }); - } - - /// Regression: a URL with multiple `${VAR}` references must - /// expand all of them. Locks down behaviour against shellexpand - /// regressions that affect consecutive references. - #[test] - fn url_with_multiple_consecutive_env_refs_expands_all() { - let mut extra = std::collections::HashMap::new(); - extra.insert("HOST".to_string(), "api.example.com".to_string()); - extra.insert("PORT".to_string(), "8443".to_string()); - extra.insert("ROUTE".to_string(), "v2/check".to_string()); - let out = crate::env_expand::expand_env_vars_with_extra( - "https://${HOST}:${PORT}/${ROUTE}", - &extra, - ); - assert_eq!(out, "https://api.example.com:8443/v2/check"); - } - - /// Regression: SSRF validation in `run_http_hook` must - /// operate on the POST-expansion URL. We construct a `HookSpec` - /// with `url: "https://${INTERNAL}/hook"` and `extra_env` mapping - /// `INTERNAL=10.0.0.1`, then call `run_http_hook` directly and - /// assert the failure carries SSRF-blocking language and that the - /// `HttpInfo.url` returned for scrollback is the post-expansion - /// form (`10.0.0.1`) rather than the literal placeholder. + /// SSRF validation in `run_http_hook` must operate on the post-expansion URL, + /// and `HttpInfo` must carry the resolved form while `raw_url` mirrors the + /// source. #[tokio::test] async fn run_http_hook_uses_post_expansion_url_for_ssrf() { let mut extra_env = std::collections::HashMap::new(); @@ -761,7 +638,7 @@ mod tests { let spec = HookSpec { name: "test-ssrf-post-expand".into(), event: HookEventName::PreToolUse, - handler_type: "http".into(), + handler_type: crate::config::HandlerType::Http, configured_matcher: None, matcher: None, enabled: true, @@ -783,12 +660,12 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::PreToolUse { tool_name: "test".into(), tool_use_id: "id-1".into(), tool_input: serde_json::json!({}), tool_input_truncated: false, - permission_mode: None, subagent_type: None, }, }; @@ -796,7 +673,7 @@ mod tests { session_id: "test", workspace_root: "/tmp", }; - let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await; + let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await; match result { crate::runner::HookRunnerResult::Failed(reason) => { @@ -813,9 +690,6 @@ mod tests { info.url, "https://10.0.0.1/hook", "HttpInfo.url must reflect the post-expansion URL (the actual target SSRF blocked)" ); - // HttpInfo.raw_url must mirror the source - // form so any future scrollback/wire-DTO consumer can prefer - // it for user-facing display. assert_eq!( info.raw_url.as_deref(), Some("https://${INTERNAL_HOST_SSRF}/hook"), @@ -823,23 +697,14 @@ mod tests { ); } - /// Regression: `reqwest::Error::Display` - /// unconditionally appends the request URL. If the resolved URL - /// embeds a secret resolved via `${TOKEN}` substitution from the - /// user `env` map, the secret would land in - /// `HookRunResult::Failed.error` and surface in pager scrollback - /// without the raw-fields work catching it. This test - /// builds a HookSpec that resolves to a guaranteed-dead host - /// (TEST-NET-1 192.0.2.0/24 from RFC 5737, used in docs) with a - /// secret-bearing query string, calls run_http_hook, and asserts - /// the secret does NOT appear in the returned error message. + /// `reqwest::Error::Display` appends the request URL, so a `${TOKEN}` secret + /// would leak into `Failed.error` and pager scrollback. Assert the secret + /// never appears in the error from a guaranteed-dead host (TEST-NET-1). #[tokio::test] async fn run_http_hook_scrubs_url_from_reqwest_error() { - // Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in - // public networks"). It is not RFC1918 so SSRF validation - // will let it through, but no real DNS or connection will - // succeed -- reqwest will surface a connection error whose - // default Display includes the URL. + // TEST-NET-1 (RFC 5737) is not RFC1918, so SSRF validation lets it + // through, but no connection succeeds: reqwest returns a connection + // error whose default Display would include the URL. let secret = "ghp_VERY_REAL_SECRET_TOKEN_42"; let mut extra_env = std::collections::HashMap::new(); extra_env.insert("RUNTIME_HOST".to_string(), "192.0.2.1".to_string()); @@ -849,7 +714,7 @@ mod tests { let spec = HookSpec { name: "test-scrub-reqwest-error".into(), event: HookEventName::PreToolUse, - handler_type: "http".into(), + handler_type: crate::config::HandlerType::Http, configured_matcher: None, matcher: None, enabled: true, @@ -857,9 +722,8 @@ mod tests { command_raw: None, url: Some(raw.to_string()), url_raw: Some(raw.to_string()), - // Short timeout so the test doesn't hang waiting for the - // dead host. Still long enough to actually attempt the - // connection so we exercise the Err(e) branch of `send().await`. + // Short but long enough to attempt the connection, so we exercise + // the Err(e) branch of `send().await` rather than a timeout. timeout_ms: 500, source_dir: std::env::temp_dir(), extra_env, @@ -873,12 +737,12 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::PreToolUse { tool_name: "test".into(), tool_use_id: "id-1".into(), tool_input: serde_json::json!({}), tool_input_truncated: false, - permission_mode: None, subagent_type: None, }, }; @@ -887,42 +751,31 @@ mod tests { workspace_root: "/tmp", }; - let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await; + let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await; - // Either `Failed` (timeout / connection error) is fine; both - // exercise paths that previously embedded the raw URL via - // `format!("...{e}")`. Pure timeouts use a different - // formatting branch (no URL involved), so prefer the - // connection-error case but tolerate either. + // Either a timeout or a connection error is fine; both previously risked + // embedding the raw URL via `format!("...{e}")`. let error_text = match result { crate::runner::HookRunnerResult::Failed(reason) => reason, other => panic!("expected Failed, got {other:?}"), }; - // The secret must NOT be in the error text. This covers BOTH - // the timeout branch (which doesn't format the URL at all, - // so trivially passes) and the connection-error branch - // (which formats `e.without_url()`, scrubbing the URL). assert!( !error_text.contains(secret), "secret leaked into error text: {error_text}" ); - // The error must mention the raw URL form (so users can see - // which hook failed) -- never the resolved form, which would - // include the secret-bearing query string. + // The connection-error branch must reference the raw URL form (so users + // see which hook failed), never the resolved secret-bearing form. if !error_text.contains("timed out") { - // Connection-error branch: error must reference the raw - // form, not the resolved one. assert!( error_text.contains("${RUNTIME_HOST}") || error_text.contains("${MY_TOKEN}"), "expected error to reference the raw URL form, got: {error_text}" ); } - // HttpInfo.url is still post-expansion (intentional, for SSRF - // debugging). The wire-DTO consumer must prefer raw_url for - // display -- documented in the HttpInfo rustdoc. + // HttpInfo.url stays post-expansion for SSRF debugging; consumers prefer + // raw_url for display (see the HttpInfo rustdoc). let info = info.expect("HttpInfo should be present for connection failures too"); assert_eq!( info.url, @@ -931,11 +784,8 @@ mod tests { assert_eq!(info.raw_url.as_deref(), Some(raw)); } - /// The hook client must not follow HTTP redirects: `validate_hook_url` - /// only vets the initial URL, so a followed 3xx would reach an unvalidated - /// target. The local server answers every request with a 302 pointing at a - /// blocked address; with redirects disabled the client returns the 302 - /// verbatim and never issues a second request to the target. + /// The hook client must not follow redirects: `validate_hook_url` only vets + /// the initial URL, so a followed 3xx would reach an unvalidated target. #[tokio::test] async fn hook_client_does_not_follow_redirects() { use std::sync::Arc; @@ -983,18 +833,13 @@ mod tests { ); } - /// Unresolved `${VAR}` refs are preserved verbatim by the helper, - /// which means `validate_hook_url` will reject the URL with an - /// "invalid URL" error. This is the desired behaviour: a hook - /// referencing an unset var must surface a clear failure rather than - /// silently smuggling the literal placeholder past validation. + /// An unresolved `${VAR}` ref is preserved verbatim, so `validate_hook_url` + /// rejects it rather than smuggling the literal placeholder past validation. #[tokio::test] async fn url_unresolved_var_fails_validation() { let key = "GROK_HOOKS_HTTP_TEST_UNRESOLVED"; - // Step 1 (sync): ensure the var is unset and run the - // expansion. `with_env_var` uses `catch_unwind` so the closure - // is synchronous; we deliberately do the async `validate_hook_url` - // call OUTSIDE the helper so we don't try to nest tokio runtimes. + // `with_env_var`'s closure is synchronous (it uses `catch_unwind`), so + // run the async `validate_hook_url` outside it to avoid nesting runtimes. let expanded = with_env_var(key, None, || { let extra = std::collections::HashMap::new(); crate::env_expand::expand_env_vars_with_extra( @@ -1002,10 +847,8 @@ mod tests { &extra, ) }); - // The literal placeholder is preserved. assert!(expanded.contains(&format!("${{{key}}}"))); - // Url::parse rejects strings with literal `${` because `{` - // isn't a valid URL character. + // Url::parse rejects the literal `${` (`{` is not a valid URL character). let result = validate_hook_url(&expanded).await; assert!(result.is_err(), "expected invalid URL error, got Ok"); } diff --git a/crates/codegen/xai-grok-hooks/src/runner/mod.rs b/crates/codegen/xai-grok-hooks/src/runner/mod.rs index 306f2cb..006e33c 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/mod.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/mod.rs @@ -5,9 +5,15 @@ use std::time::Duration; use crate::config::HookSpec; use crate::event::HookEventEnvelope; -use crate::result::{HookDecision, HttpInfo}; +use serde::Deserialize; + +use crate::result::{HookDecision, HttpInfo, StopHookOutcome}; + +/// How a hook's output is interpreted, per the event's [`GateKind`]: `Observe` +/// ignores output, `Tool` parses the allow/deny vocabulary, `Stop` the stop +/// vocabulary. +pub use crate::event::GateKind; -/// Context passed to any hook runner for environment setup. pub struct RunContext<'a> { pub session_id: &'a str, pub workspace_root: &'a str, @@ -16,40 +22,113 @@ pub struct RunContext<'a> { /// Result of running a single hook (any handler type). #[derive(Debug)] pub enum HookRunnerResult { - /// Hook ran and produced a decision (for blocking hooks). Decision(HookDecision), - /// Hook ran successfully (for non-blocking hooks). + Stop(StopHookOutcome), Success, - /// Hook failed — caller should fail-open. + /// Failed: the caller fails open. Failed(String), } -/// Bundle returned by each runner: the result, wall-clock duration, and -/// optional HTTP metadata for enriched scrollback logging. +/// JSON from `PreToolUse` gate hooks: +/// `{"decision": "allow" | "deny", "reason": "…"}`. +#[derive(Debug, Deserialize)] +pub(crate) struct GateHookJson { + pub decision: String, + #[serde(default)] + pub reason: Option, +} + +/// Interpret a [`GateHookJson`] as a [`HookDecision`]. An unknown decision value +/// is an error so typos surface instead of failing open. +pub(crate) fn gate_json_to_decision( + json: GateHookJson, + hook_name: &str, +) -> Result { + match json.decision.as_str() { + "deny" => Ok(HookDecision::Deny { + reason: json + .reason + .unwrap_or_else(|| format!("denied by hook '{hook_name}'")), + hook_name: hook_name.to_string(), + }), + "allow" => Ok(HookDecision::Allow), + other => Err(format!( + "unknown decision value '{other}' from hook '{hook_name}'" + )), + } +} + +/// JSON from `Stop`/`SubagentStop` gate hooks. All fields optional; one output +/// can combine several signals. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct StopHookJson { + #[serde(default)] + pub decision: Option, + #[serde(default)] + pub reason: Option, + #[serde(default, rename = "continue")] + pub continue_: Option, + #[serde(default, rename = "stopReason")] + pub stop_reason: Option, + #[serde(default, rename = "hookSpecificOutput")] + pub hook_specific_output: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct StopHookSpecificOutputJson { + #[serde(default, rename = "additionalContext")] + pub additional_context: Option, +} + +/// Interpret a [`StopHookJson`] as a [`StopHookOutcome`]. +/// +/// `decision: "block"` requires a reason (a missing one falls back to a generic +/// message). `decision: "approve"` is a no-op; any other value is an error so +/// typos surface. +pub(crate) fn stop_json_to_outcome( + json: StopHookJson, + hook_name: &str, +) -> Result { + let block_reason = match json.decision.as_deref() { + Some("block") => Some( + json.reason + .filter(|reason| !reason.trim().is_empty()) + .unwrap_or_else(|| format!("Blocked by stop hook '{hook_name}'")), + ), + Some("approve") | None => None, + Some(other) => { + return Err(format!( + "unknown decision value '{other}' from hook '{hook_name}'" + )); + } + }; + Ok(StopHookOutcome { + block_reason, + additional_context: json + .hook_specific_output + .and_then(|output| output.additional_context) + .filter(|context| !context.trim().is_empty()), + force_stop: (json.continue_ == Some(false)).then_some(crate::result::StopOverride { + reason: json.stop_reason, + }), + }) +} + +/// Each runner returns the result, wall-clock duration, and optional HTTP +/// metadata for enriched scrollback logging. pub type HookRunOutput = (HookRunnerResult, Duration, Option); -/// Run a hook using the appropriate handler for its type. -/// -/// Dispatches to `command::run_command_hook()` or `http::run_http_hook()` -/// based on `spec.handler_type`. Returns the result, elapsed duration, and -/// optional HTTP metadata for scrollback enrichment. pub async fn run_hook( spec: &HookSpec, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, - is_blocking: bool, + mode: GateKind, ) -> HookRunOutput { - match spec.handler_type.as_str() { - "command" => { - let (result, elapsed) = - command::run_command_hook(spec, envelope, ctx, is_blocking).await; + match spec.handler_type { + crate::config::HandlerType::Command => { + let (result, elapsed) = command::run_command_hook(spec, envelope, ctx, mode).await; (result, elapsed, None) } - "http" => http::run_http_hook(spec, envelope, ctx, is_blocking).await, - _ => ( - HookRunnerResult::Failed(format!("unsupported handler type '{}'", spec.handler_type)), - Duration::ZERO, - None, - ), + crate::config::HandlerType::Http => http::run_http_hook(spec, envelope, ctx, mode).await, } } diff --git a/crates/codegen/xai-grok-hooks/tests/integration.rs b/crates/codegen/xai-grok-hooks/tests/integration.rs index 9619081..278ebdc 100644 --- a/crates/codegen/xai-grok-hooks/tests/integration.rs +++ b/crates/codegen/xai-grok-hooks/tests/integration.rs @@ -1,11 +1,8 @@ //! Integration tests for xai-grok-hooks. //! -//! These tests use inline shell command strings (routed via `sh -c`) to verify -//! the full dispatch pipeline: discovery → matching → execution → result. -//! -//! All hook commands are inline strings (containing spaces/pipes/semicolons) -//! rather than standalone executable script files. This avoids `noexec` tmpdir -//! issues in hermetic CI sandboxes where `chmod +x` on temp files may not work. +//! Hooks use inline shell command strings routed via `sh -c` rather than +//! standalone scripts, avoiding `noexec` tmpdir issues in hermetic CI sandboxes +//! where `chmod +x` may not work. use std::path::Path; @@ -15,12 +12,10 @@ use xai_grok_hooks::event::*; use xai_grok_hooks::result::HookDecision; use xai_grok_hooks::runner::RunContext; -/// Helper: write a JSON hook file. fn write_hook(dir: &Path, filename: &str, content: &str) { std::fs::write(dir.join(filename), content).unwrap(); } -/// Helper: create a pre_tool_use envelope. fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, @@ -31,18 +26,17 @@ fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::PreToolUse { tool_name: tool_name.into(), tool_use_id: "call-1".into(), tool_input: serde_json::json!({"command": "echo hello"}), tool_input_truncated: false, - permission_mode: None, subagent_type: None, }, } } -/// Helper: create a session_start envelope. fn session_start_envelope() -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::SessionStart, @@ -53,6 +47,7 @@ fn session_start_envelope() -> HookEventEnvelope { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload: HookPayload::SessionStart { source: "new".into(), model_id: None, @@ -61,71 +56,10 @@ fn session_start_envelope() -> HookEventEnvelope { } } -#[tokio::test] -async fn hook_allows_via_json() { - let dir = tempfile::tempdir().unwrap(); - - write_hook( - dir.path(), - "safety.json", - r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo '{\"decision\":\"allow\"}'","timeout":3}]}]}}"#, - ); - - let (registry, errors) = load_hooks(Some(dir.path()), None); - assert!(errors.is_empty(), "errors: {errors:?}"); - - let ctx = RunContext { - session_id: "test", - workspace_root: dir.path().to_str().unwrap(), - }; - - let result = dispatcher::dispatch_pre_tool_use( - ®istry, - &pre_tool_use_envelope("run_terminal_cmd"), - &ctx, - ) - .await; - assert_eq!(result.decision, HookDecision::Allow); -} - -#[tokio::test] -async fn hook_denies_via_json() { - let dir = tempfile::tempdir().unwrap(); - - // Inline command: echo deny JSON and exit 2. - write_hook( - dir.path(), - "safety.json", - r#"{"hooks":{"PreToolUse":[{"matcher":"run_terminal_cmd","hooks":[{"type":"command","command":"echo '{\"decision\":\"deny\",\"reason\":\"dangerous\"}'; exit 2","timeout":3}]}]}}"#, - ); - - let (registry, errors) = load_hooks(Some(dir.path()), None); - assert!(errors.is_empty()); - - let ctx = RunContext { - session_id: "test", - workspace_root: dir.path().to_str().unwrap(), - }; - - let result = dispatcher::dispatch_pre_tool_use( - ®istry, - &pre_tool_use_envelope("run_terminal_cmd"), - &ctx, - ) - .await; - match result.decision { - HookDecision::Deny { reason, .. } => { - assert_eq!(reason, "dangerous"); - } - other => panic!("expected Deny, got {other:?}"), - } -} - #[tokio::test] async fn hook_deny_via_exit_code_only() { let dir = tempfile::tempdir().unwrap(); - // Inline command: no JSON output, just exit code 2. write_hook( dir.path(), "safety.json", @@ -155,9 +89,6 @@ async fn hook_deny_via_exit_code_only() { async fn hook_fail_open_on_crash() { let dir = tempfile::tempdir().unwrap(); - // Inline command: exit code 1 = hook failure. Under the fail-open - // policy the tool call must NOT be blocked; the failure is recorded - // for the UI but only an explicit `deny` decision blocks. write_hook( dir.path(), "safety.json", @@ -191,8 +122,6 @@ async fn hook_fail_open_on_crash() { async fn hook_fail_open_on_timeout() { let dir = tempfile::tempdir().unwrap(); - // Inline command: sleep longer than the timeout. Under fail-open - // the timeout must not block the tool call. write_hook( dir.path(), "safety.json", @@ -235,7 +164,6 @@ async fn matcher_filters_tool_name() { workspace_root: dir.path().to_str().unwrap(), }; - // Matching tool name → denied. let pre_result = dispatcher::dispatch_pre_tool_use( ®istry, &pre_tool_use_envelope("run_terminal_cmd"), @@ -244,7 +172,6 @@ async fn matcher_filters_tool_name() { .await; assert!(matches!(pre_result.decision, HookDecision::Deny { .. })); - // Non-matching tool name → allowed (hook doesn't run). let pre_result = dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx) .await; @@ -288,7 +215,6 @@ async fn non_blocking_dispatch() { async fn first_deny_stops_chain() { let dir = tempfile::tempdir().unwrap(); - // Two files: first denies, second allows. Second should never run. write_hook( dir.path(), "01-deny.json", @@ -326,7 +252,6 @@ async fn first_deny_stops_chain() { async fn hook_receives_stdin_envelope() { let dir = tempfile::tempdir().unwrap(); - // Inline command: read stdin, check it contains expected fields, allow if valid. write_hook( dir.path(), "check.json", @@ -347,50 +272,10 @@ async fn hook_receives_stdin_envelope() { assert_eq!(pre_result.decision, HookDecision::Allow); } -#[tokio::test] -async fn hook_receives_env_vars() { - let dir = tempfile::tempdir().unwrap(); - - // Inline command: check env vars and write results to a file. - let output_file = dir.path().join("env_output.txt"); - let cmd = format!( - r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo '{{"decision":"allow"}}'"#, - f = output_file.display(), - ); - let hook_json = serde_json::json!({ - "hooks": { - "PreToolUse": [ - { "hooks": [{ "type": "command", "command": cmd }] } - ] - } - }); - write_hook(dir.path(), "env.json", &hook_json.to_string()); - - let (registry, errors) = load_hooks(Some(dir.path()), None); - assert!(errors.is_empty()); - - let ctx = RunContext { - session_id: "sess-456", - workspace_root: dir.path().to_str().unwrap(), - }; - - let pre_result = - dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx) - .await; - assert_eq!(pre_result.decision, HookDecision::Allow); - - // Verify env vars were received. - let output = std::fs::read_to_string(&output_file).unwrap(); - assert!(output.contains("EVENT=pre_tool_use"), "output: {output}"); - assert!(output.contains("NAME="), "output: {output}"); // auto-generated name - assert!(output.contains("SESSION=sess-456"), "output: {output}"); -} - #[tokio::test] async fn shell_pipe_command_works() { let dir = tempfile::tempdir().unwrap(); - // Shell command with a pipe -- must go through sh -c. write_hook( dir.path(), "pipe.json", @@ -421,6 +306,7 @@ fn make_envelope(event: HookEventName, payload: HookPayload) -> HookEventEnvelop transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: None, payload, } } @@ -494,11 +380,15 @@ async fn new_event_types_fire_and_receive_correct_envelope() { event_name: HookEventName::StopFailure, json_key: "StopFailure", payload: HookPayload::StopFailure { - error: "rate_limit".into(), + error: xai_grok_hooks::event::StopFailureKind::RateLimit, + error_details: Some("429 Too Many Requests".into()), + last_assistant_message: Some("Turn failed: rate limited".into()), }, assertions: vec![ ("hookEventName", "stop_failure".into()), ("error", "rate_limit".into()), + ("errorDetails", "429 Too Many Requests".into()), + ("lastAssistantMessage", "Turn failed: rate limited".into()), ], }, ]; @@ -579,7 +469,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() { let dir = tempfile::tempdir().unwrap(); let output_file = dir.path().join("envcap.txt"); - // The hook writes the values it sees for each reserved key. let cmd = format!( r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo "ROOT=$GROK_WORKSPACE_ROOT" >> {f}; echo "PROJ=$CLAUDE_PROJECT_DIR" >> {f}; echo "USER_KEY=$USER_KEY" >> {f}; echo '{{"decision":"allow"}}'"#, f = output_file.display(), @@ -627,7 +516,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() { assert_eq!(result.decision, HookDecision::Allow); let captured = std::fs::read_to_string(&output_file).unwrap(); - // Reserved keys: runner values must win (NOT the spoofed values). assert!( captured.contains("EVENT=pre_tool_use"), "GROK_HOOK_EVENT must reflect the real event, got:\n{captured}" @@ -660,7 +548,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() { !captured.contains("PROJ=/spoofed/project"), "spoofed CLAUDE_PROJECT_DIR must NOT leak through" ); - // Non-reserved key: user value passes through. assert!( captured.contains("USER_KEY=user_value_kept"), "non-reserved user-declared env keys must pass through, got:\n{captured}" @@ -682,7 +569,6 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() { // need to mutate global state. let tmpdir_str = dir.path().to_string_lossy().into_owned(); - // Create the script in tempdir with executable bit. let script = dir.path().join("check.sh"); std::fs::write( &script, @@ -706,9 +592,9 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() { "type": "command", // No shell metachars apart from `${...}`. The // load-time pass resolves `${ROOT}` to the - // tmpdir path, leaving "/tmp.../check.sh" - // with NO `$` -- the runner picks the - // direct-exec branch. + // tmpdir path, leaving "/tmp.../check.sh" with + // no `$`, so the runner picks the direct-exec + // branch. "command": "${ROOT}/check.sh", "env": { "ROOT": tmpdir_str } } @@ -722,7 +608,6 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() { let (registry, errors) = load_hooks(Some(dir.path()), None); assert!(errors.is_empty(), "errors: {errors:?}"); - // Sanity: the loaded spec already has the resolved path with no `$`. let specs: Vec<_> = registry .hooks_for(HookEventName::PreToolUse) .iter() @@ -868,7 +753,7 @@ async fn lenient_parsing_with_mixed_claude_events() { "PreCompact": [ { "hooks": [{ "type": "command", "command": "echo compact" }] } ], - // Unknown external-only events — must not break the above. + // Unknown external-only events; must not break the above. "PermissionRequest": [ { "hooks": [{ "type": "command", "command": "echo perm-req" }] } ], diff --git a/crates/codegen/xai-grok-markdown/src/colors.rs b/crates/codegen/xai-grok-markdown/src/colors.rs index d8c8970..08fb7f2 100644 --- a/crates/codegen/xai-grok-markdown/src/colors.rs +++ b/crates/codegen/xai-grok-markdown/src/colors.rs @@ -133,12 +133,32 @@ fn terminal_supports_truecolor() -> bool { /// `ColorLevel` declaration-order discriminant. static COLOR_LEVEL_CAP: AtomicU8 = AtomicU8::new(ColorLevel::TrueColor as u8); +/// When set, RGB syntax colors are remapped with [`polarity_safe_syntax_ansi`] +/// instead of nearest-ANSI16. Used by pager minimal mode: the canvas is the +/// terminal's own bg, so night-theme pastels quantized to White vanish on +/// light profiles. See `xai-grok-pager-render` syntax docs. +static POLARITY_SAFE_SYNTAX: AtomicU8 = AtomicU8::new(0); + /// Set the process-wide upper bound on the effective color level. Pass /// [`ColorLevel::TrueColor`] to remove the cap. pub fn set_color_level_cap(cap: ColorLevel) { COLOR_LEVEL_CAP.store(cap as u8, Ordering::Relaxed); } +/// Engage dual-polarity-safe syntax color remapping (minimal / terminal-native). +/// +/// When enabled, [`adapt_color`] maps near-gray RGB to "no color" (inherit +/// terminal default fg) and chromatic RGB to base ANSI accents — never White. +pub fn set_polarity_safe_syntax(enabled: bool) { + POLARITY_SAFE_SYNTAX.store(u8::from(enabled), Ordering::Relaxed); +} + +/// Whether polarity-safe syntax remapping is active. +#[must_use] +pub fn polarity_safe_syntax() -> bool { + POLARITY_SAFE_SYNTAX.load(Ordering::Relaxed) != 0 +} + fn color_level_cap() -> ColorLevel { match COLOR_LEVEL_CAP.load(Ordering::Relaxed) { 0 => ColorLevel::None, @@ -169,7 +189,14 @@ pub fn set_color_level(level: ColorLevel) -> Result<(), ColorLevel> { /// - 256-color terminals: RGB colors are converted to closest ANSI 256 color /// - Basic terminals: colors are converted to closest ANSI 16 color /// - No color: returns None +/// +/// When [`polarity_safe_syntax`] is enabled (minimal mode), RGB tokens take +/// the dual-polarity path instead of nearest-ANSI16. pub fn adapt_color(color: Color) -> Option { + if polarity_safe_syntax() { + return adapt_color_polarity_safe(color); + } + let level = get_color_level(); match level { @@ -187,6 +214,106 @@ pub fn adapt_color(color: Color) -> Option { } } +/// Polarity-safe remap for syntax tokens painted on a transparent canvas. +/// +/// - Near-gray RGB → `None` (inherit terminal default fg) +/// - Chromatic RGB → base ANSI Red/Green/Yellow/Blue/Magenta/Cyan +/// - Existing ANSI → demote bright white / white body slots to `None`; keep accents +fn adapt_color_polarity_safe(color: Color) -> Option { + match color { + Color::Rgb(rgb) => polarity_safe_syntax_ansi(rgb.0, rgb.1, rgb.2).map(Color::Ansi), + Color::Ansi256(idx) => { + // Expand xterm index to an approximate RGB then re-map. + let (r, g, b) = ansi256_to_rgb(idx.index()); + polarity_safe_syntax_ansi(r, g, b).map(Color::Ansi) + } + Color::Ansi(ansi) => match ansi { + // Body-ish slots that flip polarity → inherit default fg. + AnsiColor::Black + | AnsiColor::White + | AnsiColor::BrightBlack + | AnsiColor::BrightWhite => None, + // Demote bright accents to base (brights can wash out on light). + AnsiColor::BrightRed => Some(Color::Ansi(AnsiColor::Red)), + AnsiColor::BrightGreen => Some(Color::Ansi(AnsiColor::Green)), + AnsiColor::BrightYellow => Some(Color::Ansi(AnsiColor::Yellow)), + AnsiColor::BrightBlue => Some(Color::Ansi(AnsiColor::Blue)), + AnsiColor::BrightMagenta => Some(Color::Ansi(AnsiColor::Magenta)), + AnsiColor::BrightCyan => Some(Color::Ansi(AnsiColor::Cyan)), + other => Some(Color::Ansi(other)), + }, + } +} + +/// Dual-polarity-safe ANSI mapping for syntax tokens (minimal mode). +/// +/// Returns `None` for near-gray (caller inherits terminal default fg). +/// Chromatic hues map to base ANSI colors only — never White/Black. +pub fn polarity_safe_syntax_ansi(r: u8, g: u8, b: u8) -> Option { + let max = r.max(g).max(b) as i32; + let min = r.min(g).min(b) as i32; + let chroma = max - min; + if chroma < 40 { + return None; + } + let (ri, gi, bi) = (r as i32, g as i32, b as i32); + let h = if max == ri { + let mut h = (gi - bi) * 60 / chroma; + if h < 0 { + h += 360; + } + h + } else if max == gi { + (bi - ri) * 60 / chroma + 120 + } else { + (ri - gi) * 60 / chroma + 240 + }; + // Magenta starts at 255° so Tokyo Night purple (#bb9af7, ~261°) lands + // Magenta rather than Blue; pure blues (~221°) stay Blue. + Some(match h { + 0..30 | 330..=360 => AnsiColor::Red, + 30..90 => AnsiColor::Yellow, + 90..150 => AnsiColor::Green, + 150..210 => AnsiColor::Cyan, + 210..255 => AnsiColor::Blue, + _ => AnsiColor::Magenta, + }) +} + +/// Approximate RGB for an xterm 256-color index (cube + grayscale). +fn ansi256_to_rgb(idx: u8) -> (u8, u8, u8) { + match idx { + 0 => (0, 0, 0), + 1 => (128, 0, 0), + 2 => (0, 128, 0), + 3 => (128, 128, 0), + 4 => (0, 0, 128), + 5 => (128, 0, 128), + 6 => (0, 128, 128), + 7 => (192, 192, 192), + 8 => (128, 128, 128), + 9 => (255, 0, 0), + 10 => (0, 255, 0), + 11 => (255, 255, 0), + 12 => (0, 0, 255), + 13 => (255, 0, 255), + 14 => (0, 255, 255), + 15 => (255, 255, 255), + 16..=231 => { + let n = idx - 16; + let r = n / 36; + let g = (n / 6) % 6; + let b = n % 6; + let level = |c: u8| if c == 0 { 0 } else { 55 + 40 * c }; + (level(r), level(g), level(b)) + } + 232..=255 => { + let v = 8 + (idx - 232) * 10; + (v, v, v) + } + } +} + /// Convert an `anstyle::Style` to the appropriate color level. pub fn adapt_style(style: anstyle::Style) -> anstyle::Style { let fg = style.get_fg_color().and_then(adapt_color); @@ -284,4 +411,41 @@ mod tests { assert!(ColorLevel::Basic < ColorLevel::Ansi256); assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor); } + + #[test] + fn polarity_safe_grays_inherit_default() { + assert_eq!(polarity_safe_syntax_ansi(0xc8, 0xc8, 0xc8), None); + assert_eq!(polarity_safe_syntax_ansi(0x6c, 0x6c, 0x6c), None); + } + + #[test] + fn polarity_safe_never_white() { + for (r, g, b) in [ + (0xbb, 0x9a, 0xf7), + (0x7d, 0xcf, 0xff), + (0x7a, 0xa2, 0xf7), + (0xff, 0x9e, 0x64), + (0xf7, 0x76, 0x8e), + (0xc8, 0xc8, 0xc8), + ] { + let mapped = polarity_safe_syntax_ansi(r, g, b); + assert!( + !matches!( + mapped, + Some(AnsiColor::White | AnsiColor::BrightWhite | AnsiColor::Black) + ), + "#{r:02x}{g:02x}{b:02x} -> {mapped:?}" + ); + } + } + + #[test] + fn adapt_color_polarity_safe_flag_drops_gray_rgb() { + set_polarity_safe_syntax(true); + let out = adapt_color(Color::Rgb(RgbColor(0xc8, 0xc8, 0xc8))); + assert_eq!(out, None, "gray body must inherit default fg"); + let magenta = adapt_color(Color::Rgb(RgbColor(0xbb, 0x9a, 0xf7))); + assert_eq!(magenta, Some(Color::Ansi(AnsiColor::Magenta))); + set_polarity_safe_syntax(false); + } } diff --git a/crates/codegen/xai-grok-markdown/src/lib.rs b/crates/codegen/xai-grok-markdown/src/lib.rs index 33c7a42..cf2211d 100644 --- a/crates/codegen/xai-grok-markdown/src/lib.rs +++ b/crates/codegen/xai-grok-markdown/src/lib.rs @@ -47,7 +47,8 @@ mod url_scan; pub use buffers::MarkdownBuffers; pub use checkpoint::{Checkpoint, CheckpointKind}; pub use colors::{ - ColorLevel, adapt_color, adapt_style, detect_color_level, get_color_level, set_color_level_cap, + ColorLevel, adapt_color, adapt_style, detect_color_level, get_color_level, + polarity_safe_syntax, polarity_safe_syntax_ansi, set_color_level_cap, set_polarity_safe_syntax, }; pub use latex_delimiters::{LatexDelimiterNormalizer, normalize_latex_delimiters}; pub use output::{CodeBlockSpan, HyperlinkTarget, MarkdownRenderOutput, MarkdownRenderView}; diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index a1ddc08..7b67736 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.105" +version = "0.2.106" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-minimal/src/live.rs b/crates/codegen/xai-grok-pager-minimal/src/live.rs index b552c46..cfee242 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/live.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/live.rs @@ -876,6 +876,7 @@ mod tests { created_at: std::time::Instant::now(), next_fire_at: None, tag: "loop".to_string(), + last_subagent_id: None, }, ); assert_eq!(minimal_api::watchers(&a).loops, 1); diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs index 24a06f3..8e592c0 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs @@ -17,6 +17,10 @@ use std::sync::OnceLock; use crate::terminal::{MultiplexerKind, TerminalContext}; +/// Env var overriding where the copy backup file is written (supports `~`). +/// Documented in `xai-grok-pager/docs/internal/22-environment-variables.md`. +pub const GROK_COPY_FILE_ENV: &str = "GROK_COPY_FILE"; + /// Cached result of the remote-session check (env vars don't change at runtime). fn is_remote() -> bool { static REMOTE: OnceLock = OnceLock::new(); @@ -51,6 +55,21 @@ pub fn osc52_sink_active() -> bool { }) } +/// Kill switch: never emit OSC 52 clipboard sequences. +/// +/// Set `GROK_CLIPBOARD_NO_OSC52` (any value) before starting Grok. Presence +/// forces the OSC 52 leg off for the whole process — including Linux "always +/// emit", tmux, SSH, container, and `GROK_OSC52_SINK` paths. Use this when the +/// host terminal paints OSC 52 payloads as visible garbage (e.g. OpenText +/// Exceed and other non-supporting emulators). +/// +/// Same convention as `GROK_CLIPBOARD_NO_DATA_CONTROL`: env presence enables +/// the kill switch; resolved once and cached for the process lifetime. +pub fn osc52_disabled() -> bool { + static DISABLED: OnceLock = OnceLock::new(); + *DISABLED.get_or_init(|| std::env::var_os("GROK_CLIPBOARD_NO_OSC52").is_some()) +} + /// Cached clipboard route resolved at first use from the terminal context. pub fn clipboard_route() -> &'static ClipboardRoute { static ROUTE: OnceLock = OnceLock::new(); @@ -114,29 +133,40 @@ impl std::fmt::Display for ClipboardRoute { /// Resolve the clipboard route from a terminal context. /// -/// Note: the `osc52` field depends on [`is_remote()`] and -/// [`is_container_no_display()`] which read ambient env vars / filesystem -/// markers (cached in `OnceLock`s). In tmux-backed environments `osc52` is +/// Note: the `osc52` field depends on [`is_remote()`], +/// [`is_container_no_display()`], [`osc52_sink_active()`], and +/// [`osc52_disabled()`] which read ambient env vars / filesystem markers +/// (cached in `OnceLock`s). In tmux-backed environments `osc52` is normally /// unconditionally `true` regardless of SSH/container state, so this only -/// matters for non-tmux contexts. Tests that cannot control SSH env vars +/// matters for non-tmux contexts — unless `GROK_CLIPBOARD_NO_OSC52` is set, +/// which forces OSC 52 off everywhere. Tests that cannot control SSH env vars /// should skip asserting `osc52` for non-tmux cases. pub fn resolve_clipboard_route(ctx: &TerminalContext) -> ClipboardRoute { + resolve_clipboard_route_with(ctx, osc52_disabled()) +} + +/// Pure clipboard-route resolution (kill-switch injected for tests). +fn resolve_clipboard_route_with(ctx: &TerminalContext, no_osc52: bool) -> ClipboardRoute { let is_tmux = ctx.multiplexer == MultiplexerKind::Tmux; - ClipboardRoute { - native: true, - tmux_buffer: is_tmux, - // Linux: always emit OSC 52 as a safety net. This matches other - // terminal agent CLIs which emit OSC 52 on every copy. - // macOS/Windows: only in tmux/SSH/container contexts, or when an - // upstream `grok wrap` sink is capturing our output and will forward - // the sequence to the real clipboard. - osc52: cfg!(target_os = "linux") + // Linux: always emit OSC 52 as a safety net. This matches other + // terminal agent CLIs which emit OSC 52 on every copy. + // macOS/Windows: only in tmux/SSH/container contexts, or when an + // upstream `grok wrap` sink is capturing our output and will forward + // the sequence to the real clipboard. + // `GROK_CLIPBOARD_NO_OSC52` wins over every automatic path. + let osc52 = !no_osc52 + && (cfg!(target_os = "linux") || is_tmux || is_remote() || is_container_no_display() - || osc52_sink_active(), + || osc52_sink_active()); + ClipboardRoute { + native: true, + tmux_buffer: is_tmux, + osc52, // Editor :terminal's immediate emulator is libvterm, not tmux — don't wrap there. - osc52_tmux_passthrough: is_tmux && ctx.embedded_editor.is_none(), + // No point in tmux passthrough when OSC 52 itself is disabled. + osc52_tmux_passthrough: osc52 && is_tmux && ctx.embedded_editor.is_none(), } } @@ -265,9 +295,14 @@ fn clipboard_write_with_route(text: &str, route: &ClipboardRoute) -> ClipboardWr } /// Result of a clipboard write with toast info for the caller to display. +#[derive(Debug)] pub struct CopyResult { - /// User-facing toast message. + /// Full user-facing toast message (used when no backup file exists). pub message: &'static str, + /// Leading phrase of `message` without the trailing guidance sentence. + /// [`CopyDelivery::toast_message`] appends the dynamic backup-file path + /// to this compact lead instead of the full message. + pub message_lead: &'static str, /// Toast duration in ticks (30fps: 30 = ~1s, 120 = ~4s). pub ticks: u8, /// Evidence that the write reached the destination named by the UI. @@ -317,6 +352,9 @@ impl ClipboardFeedback { } /// User-facing toast message for this kind. + /// + /// Must start with [`Self::message_lead`] (asserted in tests) so the + /// path-bearing toast built from the lead never rewords the static copy. fn message(self) -> &'static str { match self { Self::Copied => "Copied!", @@ -333,6 +371,22 @@ impl ClipboardFeedback { } } + /// Leading phrase of [`Self::message`] (no trailing period). When a + /// backup file exists, the toast is just this lead plus the path — the + /// guidance tail is dropped because the file already is the recovery + /// path and the full sentence overflows narrow terminals. + fn message_lead(self) -> &'static str { + match self { + Self::Copied => "Copied!", + Self::CopiedTmux => "Copied to tmux buffer, paste with prefix + ]", + Self::CopiedOscContainer => "Copied via OSC 52 from the container", + Self::CopiedOscRemote => "Copied via OSC 52", + Self::UnverifiedOscRemote | Self::UnverifiedOscContainer => "Copy sent", + Self::VsCodeSshNonAscii => "Copied", + Self::FailedRemote | Self::Failed => "Copy failed", + } + } + /// Toast duration in ticks (30fps: 30 = ~1s, 120 = ~4s). fn ticks(self) -> u8 { match self { @@ -351,6 +405,7 @@ impl ClipboardFeedback { fn to_result(self) -> CopyResult { CopyResult { message: self.message(), + message_lead: self.message_lead(), ticks: self.ticks(), delivery: self.delivery(), } @@ -393,6 +448,225 @@ pub fn copy_text(text: &str) -> CopyResult { result } +/// Where a copy landed after [`copy_text_or_file`]. +#[derive(Debug)] +pub enum CopyDelivery { + /// Trusted clipboard backend accepted the write. `file` is the + /// always-written backup copy (`None` only when the file write itself + /// failed — that never fails the copy). + Clipboard { + result: CopyResult, + file: Option, + }, + /// Clipboard failed; text was written to this path instead. + File { path: std::path::PathBuf }, + /// Clipboard and file fallback both failed. + Failed { + clipboard: CopyResult, + file_error: std::io::Error, + }, +} + +impl CopyDelivery { + /// `true` when the user can retrieve the text (clipboard or file). + pub fn success(&self) -> bool { + !matches!(self, Self::Failed { .. }) + } + + /// User-facing toast line for this delivery. Every clipboard success with + /// a backup file names its path. The guidance tail is dropped in that + /// case — the file already is the recovery path, and lead + path + tail + /// overflows narrow terminals (the toast renderer would truncate it). + pub fn toast_message(&self) -> std::borrow::Cow<'static, str> { + use std::borrow::Cow; + match self { + Self::Clipboard { result, file } => match file { + Some(path) => Cow::Owned(format!( + "{} — saved to {}", + result.message_lead, + display_copy_path(path) + )), + None => Cow::Borrowed(result.message), + }, + Self::File { path } => Cow::Owned(format!( + "Clipboard unreachable — wrote {}", + display_copy_path(path) + )), + Self::Failed { clipboard, .. } => Cow::Borrowed(clipboard.message), + } + } + + /// Toast duration in ticks for [`Self::toast_message`]. + pub fn toast_ticks(&self) -> u8 { + match self { + Self::Clipboard { result, .. } => result.ticks, + Self::File { .. } => 120, + Self::Failed { clipboard, .. } => clipboard.ticks, + } + } +} + +/// Default path for the always-written copy backup file. +/// +/// Override with [`GROK_COPY_FILE_ENV`] (supports `~`). Otherwise +/// `~/.grok/last-copy.txt` (grok's per-user home — short, stable, and +/// readable in a toast, unlike macOS's `/var/folders/...` temp dir). +/// +/// `None` when no grok home resolves and the env var is unset: rather than +/// writing to a predictable world-visible temp path, the backup file is +/// simply skipped (the clipboard legs still fire). +pub fn default_copy_fallback_path() -> Option { + if let Ok(raw) = std::env::var(GROK_COPY_FILE_ENV) { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return Some(std::path::PathBuf::from( + shellexpand::tilde(trimmed).as_ref(), + )); + } + } + xai_grok_config::user_grok_home().map(|grok_home| grok_home.join("last-copy.txt")) +} + +/// Render a backup-file path for user-facing messages using the codebase-wide +/// abbreviation convention ([`crate::util::abbreviate_path`]): a grok-home +/// prefix collapses to `~/.grok` (or `$GROK_HOME` when overridden), and a +/// plain home prefix collapses to `~` — so toasts stay short. +pub fn display_copy_path(path: &std::path::Path) -> String { + crate::util::abbreviate_path(&path.to_string_lossy()).into_owned() +} + +/// Write `text` to `path` (tilde-expand, create parent dirs). Returns the +/// expanded path on success. +/// +/// On unix the file is written `0600` (owner-only): copied text can be +/// sensitive and the default fallback path is predictable, so other local +/// users must not be able to read it. +pub fn write_text_to_copy_file( + text: &str, + path: &std::path::Path, +) -> std::io::Result { + let expanded = std::path::PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()); + if let Some(parent) = expanded.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + write_owner_only(&expanded, text)?; + Ok(expanded) +} + +/// Write `text` to `path`, owner-readable only (`0600`) on unix. +/// +/// A pre-existing file (e.g. a `last-copy.txt` created `0644` by an older +/// grok) is tightened via `set_permissions` since the create-time `mode` +/// only applies to newly created files. Non-unix falls back to a plain write. +fn write_owner_only(path: &std::path::Path, text: &str) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.write_all(text.as_bytes()) + } + #[cfg(not(unix))] + std::fs::write(path, text) +} + +/// Write to the default fallback path ([`default_copy_fallback_path`]). +/// +/// Errors with `NotFound` when no fallback path resolves (no home and no +/// `GROK_COPY_FILE`) — the backup file is skipped rather than written to a +/// predictable temp location. +/// +/// On Unix a missing parent directory is created `0700` (a custom +/// `GROK_COPY_FILE` may point at a not-yet-created private directory; +/// `~/.grok` normally already exists). +pub fn write_copy_fallback(text: &str) -> std::io::Result { + let Some(path) = default_copy_fallback_path() else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no home directory resolves; set GROK_COPY_FILE to enable the copy backup file", + )); + }; + #[cfg(unix)] + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent)?; + } + write_text_to_copy_file(text, &path) +} + +/// Compose a [`CopyDelivery`] from the clipboard toast and the (always +/// attempted) backup-file write. Pure so the matrix is unit-testable without +/// firing real clipboard legs. +fn resolve_delivery( + clipboard: CopyResult, + file: std::io::Result, +) -> CopyDelivery { + if clipboard.delivery.reported_success() { + return CopyDelivery::Clipboard { + result: clipboard, + file: file.ok(), + }; + } + match file { + Ok(path) => CopyDelivery::File { path }, + Err(file_error) => CopyDelivery::Failed { + clipboard, + file_error, + }, + } +} + +/// Fire the normal clipboard route AND always write the backup file +/// (Claude Code parity: every copy lands in a file too). +/// +/// The file is the recovery path for terminals that cannot reach the local +/// clipboard over SSH (notably Apple Terminal without `grok wrap`); a failed +/// file write never fails a copy whose clipboard leg succeeded. +pub fn copy_text_or_file(text: &str) -> CopyDelivery { + let clipboard = copy_text(text); + let file = write_copy_fallback(text); + match &file { + Ok(path) => { + if !clipboard.delivery.reported_success() { + tracing::info!( + path = %path.display(), + len = text.len(), + "clipboard unreachable; copy retrievable from backup file" + ); + } + } + Err(error) => { + if clipboard.delivery.reported_success() { + tracing::debug!( + error = %error, + len = text.len(), + "copy backup file write failed (clipboard succeeded)" + ); + } else { + tracing::warn!( + error = %error, + len = text.len(), + "clipboard and copy file fallback both failed" + ); + } + } + } + resolve_delivery(clipboard, file) +} + fn log_clipboard_copy_event( text: &str, route: &ClipboardRoute, @@ -1517,7 +1791,9 @@ mod tests { ]; for case in cases { - let route = resolve_clipboard_route(&case.ctx); + // Pure helper with kill switch off so ambient GROK_CLIPBOARD_NO_OSC52 + // cannot flake CI (route() itself still reads the real env). + let route = resolve_clipboard_route_with(&case.ctx, false); assert_eq!( route.native, case.native, "native mismatch on case '{}'", @@ -1595,9 +1871,9 @@ mod tests { #[test] fn clipboard_route_osc52_always_for_tmux_backed() { // In tmux-backed environments, OSC 52 is always emitted regardless of - // remote session status. + // remote session status (unless the kill switch is on — tested below). for ctx in [plain_tmux_ctx(), byobu_tmux_ctx()] { - let route = resolve_clipboard_route(&ctx); + let route = resolve_clipboard_route_with(&ctx, false); assert!( route.osc52, "OSC 52 should always be emitted in tmux-backed env: {:?}", @@ -1606,16 +1882,49 @@ mod tests { } } + #[test] + fn clipboard_route_no_osc52_kill_switch_forces_off() { + // GROK_CLIPBOARD_NO_OSC52 must win over Linux/tmux/SSH automatic emit. + for ctx in [ + plain_terminal_ctx(), + plain_tmux_ctx(), + byobu_tmux_ctx(), + byobu_screen_ctx(), + zellij_ctx(), + plain_screen_ctx(), + ] { + let route = resolve_clipboard_route_with(&ctx, true); + assert!( + !route.osc52, + "OSC 52 must be off under kill switch for {:?}", + ctx.multiplexer + ); + assert!( + !route.osc52_tmux_passthrough, + "tmux passthrough must be off when OSC 52 is killed for {:?}", + ctx.multiplexer + ); + // Other legs are unaffected. + assert!(route.native); + } + // tmux buffer still active when in tmux — only OSC 52 is killed. + let tmux = resolve_clipboard_route_with(&plain_tmux_ctx(), true); + assert!(tmux.tmux_buffer); + assert!(!tmux.osc52); + } + #[test] fn clipboard_route_osc52_tmux_passthrough_truth_table() { // tmux + no editor: wrap (tmux is the immediate terminal). - assert!(resolve_clipboard_route(&plain_tmux_ctx()).osc52_tmux_passthrough); + assert!(resolve_clipboard_route_with(&plain_tmux_ctx(), false).osc52_tmux_passthrough); // tmux + embedded editor: don't wrap (libvterm is the immediate terminal). let mut tmux_in_editor = plain_tmux_ctx(); tmux_in_editor.embedded_editor = Some(EmbeddedEditor::Neovim); - assert!(!resolve_clipboard_route(&tmux_in_editor).osc52_tmux_passthrough); + assert!(!resolve_clipboard_route_with(&tmux_in_editor, false).osc52_tmux_passthrough); // non-tmux: never wrap. - assert!(!resolve_clipboard_route(&plain_terminal_ctx()).osc52_tmux_passthrough); + assert!(!resolve_clipboard_route_with(&plain_terminal_ctx(), false).osc52_tmux_passthrough); + // kill switch: never wrap even in plain tmux. + assert!(!resolve_clipboard_route_with(&plain_tmux_ctx(), true).osc52_tmux_passthrough); } // ===================================================================== @@ -1674,7 +1983,7 @@ mod tests { #[test] fn clipboard_route_tmux_backed_all_three_legs() { for ctx in [plain_tmux_ctx(), byobu_tmux_ctx()] { - let route = resolve_clipboard_route(&ctx); + let route = resolve_clipboard_route_with(&ctx, false); assert!(route.native, "native should be true"); assert!(route.tmux_buffer, "tmux_buffer should be true"); assert!(route.osc52, "osc52 should be true for tmux-backed"); @@ -1775,6 +2084,266 @@ mod tests { assert_eq!(result.message, message); assert_eq!(result.ticks, ticks); assert_eq!(result.delivery, delivery); + // The lead must prefix the full message so the path-bearing + // toast never rewords the static copy. + assert!( + message.starts_with(result.message_lead), + "message_lead must prefix message for {feedback:?}" + ); + } + } + + #[test] + fn write_text_to_copy_file_creates_parent_and_writes() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("nested").join("copy.txt"); + let written = write_text_to_copy_file("hello fallback", &path).expect("write"); + assert_eq!(written, path); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "hello fallback" + ); + } + + /// Copied text can be sensitive and the fallback path is predictable, so + /// the file must be owner-only (`0600`) — including when an older grok + /// left a pre-existing `0644` file behind. + #[cfg(unix)] + #[test] + fn copy_file_is_owner_only_0600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("copy.txt"); + + // Fresh file: created 0600. + write_text_to_copy_file("secret", &path).expect("write"); + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "fresh copy file must be 0600"); + + // Pre-existing world-readable file: tightened to 0600 on rewrite. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("loosen for test"); + write_text_to_copy_file("secret2", &path).expect("rewrite"); + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "pre-existing copy file must be tightened to 0600" + ); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "secret2"); + } + + #[test] + #[serial_test::serial(grok_copy_file)] + fn default_copy_fallback_path_respects_grok_copy_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let custom = dir.path().join("custom-copy.txt"); + // SAFETY: test-only env mutation; serialized on the grok_copy_file key. + unsafe { + std::env::set_var(GROK_COPY_FILE_ENV, &custom); + } + let resolved = default_copy_fallback_path(); + unsafe { + std::env::remove_var(GROK_COPY_FILE_ENV); + } + assert_eq!(resolved, Some(custom)); + } + + #[test] + #[serial_test::serial(grok_copy_file)] + fn write_copy_fallback_uses_env_override() { + let dir = tempfile::tempdir().expect("tempdir"); + let custom = dir.path().join("last.txt"); + unsafe { + std::env::set_var(GROK_COPY_FILE_ENV, &custom); + } + let written = write_copy_fallback("payload").expect("fallback write"); + unsafe { + std::env::remove_var(GROK_COPY_FILE_ENV); + } + assert_eq!(written, custom); + assert_eq!(std::fs::read_to_string(&custom).expect("read"), "payload"); + } + + /// Without `GROK_COPY_FILE`, the default is `~/.grok/last-copy.txt` + /// (grok home) — short and toast-friendly, unlike macOS's temp dir. + #[test] + #[serial_test::serial(grok_copy_file)] + fn default_copy_fallback_path_is_grok_home() { + unsafe { + std::env::remove_var(GROK_COPY_FILE_ENV); + } + let path = default_copy_fallback_path(); + // Test envs always resolve a home (or set GROK_HOME). + let expected = xai_grok_config::user_grok_home() + .expect("home resolves in tests") + .join("last-copy.txt"); + assert_eq!(path, Some(expected)); + } + + /// Toast paths collapse the home prefix to `~` (grok-home paths go + /// through the shared `abbreviate_path` convention, covered further by + /// the `GROK_HOME`-override integration test in `xai-grok-pager`). + #[test] + fn display_copy_path_abbreviates_home() { + if std::env::var_os("GROK_HOME").is_none() { + let home = dirs::home_dir().expect("home resolves in tests"); + assert_eq!( + display_copy_path(&home.join(".grok").join("last-copy.txt")), + "~/.grok/last-copy.txt" + ); + } + // Non-home paths pass through untouched — including multi-byte + // UTF-8 components (must never slice at a non-char boundary). + assert_eq!( + display_copy_path(std::path::Path::new("/tmp/grok-0/last-copy.txt")), + "/tmp/grok-0/last-copy.txt" + ); + assert_eq!( + display_copy_path(std::path::Path::new("/tmp/日本語/コピー.txt")), + "/tmp/日本語/コピー.txt" + ); + } + + // -- resolve_delivery: pure clipboard × file composition matrix ---------- + + fn copy_result(success: bool) -> CopyResult { + CopyResult { + message: "test", + message_lead: "test", + ticks: 30, + delivery: if success { + ClipboardDelivery::Confirmed + } else { + ClipboardDelivery::Failed + }, + } + } + + #[test] + fn delivery_clipboard_success_carries_backup_file() { + let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt"); + match resolve_delivery(copy_result(true), Ok(path.clone())) { + CopyDelivery::Clipboard { result, file } => { + assert!(result.delivery.reported_success()); + assert_eq!(file, Some(path)); + } + other => panic!("expected Clipboard delivery, got {other:?}"), + } + } + + /// A failed backup write never fails a copy whose clipboard succeeded. + #[test] + fn delivery_clipboard_success_survives_file_write_failure() { + let err = std::io::Error::other("disk full"); + let delivery = resolve_delivery(copy_result(true), Err(err)); + assert!(delivery.success()); + match delivery { + CopyDelivery::Clipboard { file, .. } => assert!(file.is_none()), + other => panic!("expected Clipboard delivery, got {other:?}"), + } + } + + /// Clipboard `Failed` still yields `File` delivery (the pre-existing + /// fallback contract). + #[test] + fn delivery_clipboard_failure_yields_file() { + let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt"); + let delivery = resolve_delivery(copy_result(false), Ok(path.clone())); + assert!(delivery.success()); + match delivery { + CopyDelivery::File { path: p } => assert_eq!(p, path), + other => panic!("expected File delivery, got {other:?}"), + } + } + + #[test] + fn delivery_both_failed_is_failed() { + let err = std::io::Error::other("read-only fs"); + let delivery = resolve_delivery(copy_result(false), Err(err)); + assert!(!delivery.success()); + assert!(matches!(delivery, CopyDelivery::Failed { .. })); + } + + // -- CopyDelivery toast composition --------------------------------------- + + #[test] + fn toast_message_always_names_backup_file() { + let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt"); + + // Plain success with a backup: names the path. + let plain = CopyDelivery::Clipboard { + result: ClipboardFeedback::Copied.to_result(), + file: Some(path.clone()), + }; + assert_eq!( + plain.toast_message(), + "Copied! — saved to /tmp/grok-1/last-copy.txt" + ); + assert_eq!(plain.toast_ticks(), 30); + + // Unverified OSC 52 with a backup: compact lead + path, guidance tail + // dropped (the file is the recovery path; the full sentence overflows + // narrow terminals). + let unverified = CopyDelivery::Clipboard { + result: ClipboardFeedback::UnverifiedOscRemote.to_result(), + file: Some(path.clone()), + }; + assert_eq!( + unverified.toast_message(), + "Copy sent — saved to /tmp/grok-1/last-copy.txt" + ); + assert_eq!(unverified.toast_ticks(), 120); + + // No backup file (write failed): falls back to the static message. + let no_file = CopyDelivery::Clipboard { + result: ClipboardFeedback::UnverifiedOscRemote.to_result(), + file: None, + }; + assert_eq!( + no_file.toast_message(), + ClipboardFeedback::UnverifiedOscRemote.message() + ); + + // File-only delivery keeps the "unreachable" wording. + let file_only = CopyDelivery::File { path }; + assert_eq!( + file_only.toast_message(), + "Clipboard unreachable — wrote /tmp/grok-1/last-copy.txt" + ); + assert_eq!(file_only.toast_ticks(), 120); + + // Failed delivery surfaces the clipboard failure message. + let failed = CopyDelivery::Failed { + clipboard: ClipboardFeedback::Failed.to_result(), + file_error: std::io::Error::other("nope"), + }; + assert_eq!(failed.toast_message(), ClipboardFeedback::Failed.message()); + assert_eq!(failed.toast_ticks(), 120); + } + + /// An UNVERIFIED clipboard delivery still counts as a clipboard delivery + /// (not a file fallback): the toast hedges but the backup path is named. + #[test] + fn unverified_clipboard_delivery_composes_as_clipboard() { + let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt"); + let delivery = resolve_delivery( + ClipboardFeedback::UnverifiedOscRemote.to_result(), + Ok(path.clone()), + ); + match delivery { + CopyDelivery::Clipboard { result, file } => { + assert_eq!(result.delivery, ClipboardDelivery::Unverified); + assert_eq!(file, Some(path)); + } + other => panic!("expected Clipboard delivery, got {other:?}"), } } } diff --git a/crates/codegen/xai-grok-pager-render/src/syntax.rs b/crates/codegen/xai-grok-pager-render/src/syntax.rs index e93ea65..7de80cb 100644 --- a/crates/codegen/xai-grok-pager-render/src/syntax.rs +++ b/crates/codegen/xai-grok-pager-render/src/syntax.rs @@ -3,45 +3,119 @@ //! Provides lazily-initialized `Syntect` instances for code highlighting. //! Dark themes (GrokNight, TokyoNight) share `grok-night.tmTheme`; //! GrokDay uses `grok-day.tmTheme` with deepened colors for light backgrounds. +//! +//! ## Minimal / terminal-native lock +//! +//! While [`crate::theme::cache::terminal_native_locked`] is set, chrome uses +//! [`Theme::terminal_default`](crate::theme::Theme::terminal_default) and +//! `current_kind()` is a nominal `GrokNight` (so leftover kind-keyed paths +//! still resolve). Syntect therefore loads the night `.tmTheme` whose pastel +//! RGB tokens, after naive ANSI-16 quantization, collapse to **White** — +//! invisible on light terminal profiles. +//! +//! Under the lock we do **not** detect light/dark. Instead: +//! 1. Near-gray tokens → `Color::Reset` (terminal default fg; always readable). +//! 2. Chromatic tokens → base ANSI-16 accents (Red/Green/Yellow/Blue/Magenta/Cyan), +//! never White/Black/bright variants. +//! +//! That matches the "first + second" minimal syntax policy: default-fg baseline +//! plus a dual-polarity accent map, with zero polarity detection. use std::sync::OnceLock; pub use xai_grok_markdown::Syntect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + use crate::theme::ThemeKind; static SYNTECT_GROKNIGHT: OnceLock = OnceLock::new(); static SYNTECT_TOKYONIGHT: OnceLock = OnceLock::new(); static SYNTECT_GROKDAY: OnceLock = OnceLock::new(); -/// Convert syntect style to ratatui foreground-only style, quantized for terminal color support. -pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::style::Style { - let fg = crate::theme::quantize(ratatui::style::Color::Rgb( - style.foreground.r, - style.foreground.g, - style.foreground.b, - )); - let mut out = ratatui::style::Style::default().fg(fg); +/// Convert syntect style to ratatui foreground-only style, quantized for +/// terminal color support (or polarity-safe under the terminal-native lock). +pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> Style { + let fg = syntect_rgb_to_fg(style.foreground.r, style.foreground.g, style.foreground.b); + let mut out = Style::default().fg(fg); use syntect::highlighting::FontStyle; if style.font_style.contains(FontStyle::BOLD) { - out = out.add_modifier(ratatui::style::Modifier::BOLD); + out = out.add_modifier(Modifier::BOLD); } if style.font_style.contains(FontStyle::ITALIC) { - out = out.add_modifier(ratatui::style::Modifier::ITALIC); + out = out.add_modifier(Modifier::ITALIC); } if style.font_style.contains(FontStyle::UNDERLINE) { - out = out.add_modifier(ratatui::style::Modifier::UNDERLINED); + out = out.add_modifier(Modifier::UNDERLINED); } out } +/// Map a syntect RGB triplet to a ratatui foreground color. +/// +/// Under the terminal-native lock, uses [`polarity_safe_syntax_fg`]; otherwise +/// quantizes via the normal theme color pipeline. +pub fn syntect_rgb_to_fg(r: u8, g: u8, b: u8) -> Color { + if crate::theme::cache::terminal_native_locked() { + polarity_safe_syntax_fg(r, g, b) + } else { + crate::theme::quantize(Color::Rgb(r, g, b)) + } +} + +/// Dual-polarity-safe ANSI mapping for syntax tokens on a transparent canvas. +/// +/// - Low chroma (gray / near-gray body text) → [`Color::Reset`] so the host +/// default fg carries contrast on both light and dark profiles. +/// - Saturated hues → base ANSI Red/Green/Yellow/Blue/Magenta/Cyan only. +/// +/// Never returns White, Black, or bright (Light*) variants — those are the +/// colors that vanish on the opposite polarity after naive RGB→ANSI16. +pub fn polarity_safe_syntax_fg(r: u8, g: u8, b: u8) -> Color { + let max = r.max(g).max(b) as i32; + let min = r.min(g).min(b) as i32; + let chroma = max - min; + // Night default body (~#c8c8c8) and dim comments are near-gray. + if chroma < 40 { + return Color::Reset; + } + // Integer HSV hue in degrees [0, 360). + let (ri, gi, bi) = (r as i32, g as i32, b as i32); + let h = if max == ri { + let mut h = (gi - bi) * 60 / chroma; + if h < 0 { + h += 360; + } + h + } else if max == gi { + (bi - ri) * 60 / chroma + 120 + } else { + (ri - gi) * 60 / chroma + 240 + }; + // Magenta starts at 255° so Tokyo Night purple (#bb9af7, ~261°) lands + // Magenta rather than Blue; pure blues (~221°) stay Blue. + match h { + 0..30 | 330..=360 => Color::Red, + 30..90 => Color::Yellow, + 90..150 => Color::Green, + 150..210 => Color::Cyan, + 210..255 => Color::Blue, + _ => Color::Magenta, + } +} + /// Highlight a single line of source, falling back to plain text style. +/// +/// Under the terminal-native lock, syntect tokens are remapped via +/// [`polarity_safe_syntax_fg`]; if highlighting fails, `fallback` (typically +/// [`Theme::primary`](crate::theme::Theme::primary) = Reset) is used. pub fn highlight_line( text: &str, highlighter: &mut Option>, syntect: &Syntect, - fallback: ratatui::style::Style, -) -> Vec> { + fallback: Style, +) -> Vec> { if let Some(hl) = highlighter.as_mut() && let Ok(ranges) = hl.highlight_line(&format!("{text}\n"), &syntect.syntax_set) { @@ -54,16 +128,21 @@ pub fn highlight_line( if s.is_empty() { continue; } - spans.push(ratatui::text::Span::styled(s, syntect_to_ratatui_fg(style))); + spans.push(Span::styled(s, syntect_to_ratatui_fg(style))); } if !spans.is_empty() { return spans; } } - vec![ratatui::text::Span::styled(text.to_string(), fallback)] + vec![Span::styled(text.to_string(), fallback)] } /// Returns the syntect instance matching the active theme. +/// +/// Note: while the terminal-native lock is engaged, [`Theme::current_kind`] +/// reports a nominal `GrokNight`, so this returns the night theme. Token +/// colors are remapped in [`syntect_to_ratatui_fg`] — do not load a day +/// theme based on OS/terminal polarity detection. pub fn get_syntect() -> &'static Syntect { match crate::theme::Theme::current_kind() { ThemeKind::GrokNight @@ -77,3 +156,114 @@ pub fn get_syntect() -> &'static Syntect { .get_or_init(|| Syntect::new(include_bytes!("../assets/grok-day.tmTheme"))), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::cache as theme_cache; + + /// Hold the theme test lock so we can flip the terminal-native flag. + fn with_native_lock(locked: bool, f: impl FnOnce() -> R) -> R { + let _guard = theme_cache::test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + theme_cache::reset_for_test(); + theme_cache::set_terminal_native_lock(locked); + let out = f(); + theme_cache::set_terminal_native_lock(false); + theme_cache::reset_for_test(); + out + } + + #[test] + fn polarity_safe_grays_are_reset() { + // Night default body / comments. + assert_eq!(polarity_safe_syntax_fg(0xc8, 0xc8, 0xc8), Color::Reset); + assert_eq!(polarity_safe_syntax_fg(0x6c, 0x6c, 0x6c), Color::Reset); + assert_eq!(polarity_safe_syntax_fg(0xb2, 0xb2, 0xb2), Color::Reset); + assert_eq!(polarity_safe_syntax_fg(0x44, 0x44, 0x44), Color::Reset); + } + + #[test] + fn polarity_safe_never_emits_white_or_black() { + // Common night-theme pastels that naive ANSI16 maps to White. + let samples = [ + (0xbb, 0x9a, 0xf7), // magenta + (0x7d, 0xcf, 0xff), // cyan + (0x7a, 0xa2, 0xf7), // blue + (0xff, 0x9e, 0x64), // orange + (0xf7, 0x76, 0x8e), // red + (0xe0, 0xaf, 0x68), // yellow + (0x9e, 0xce, 0x6a), // green + (0xc8, 0xc8, 0xc8), // gray body + ]; + for (r, g, b) in samples { + let c = polarity_safe_syntax_fg(r, g, b); + assert!( + !matches!( + c, + Color::White + | Color::Black + | Color::Gray + | Color::DarkGray + | Color::LightRed + | Color::LightGreen + | Color::LightYellow + | Color::LightBlue + | Color::LightMagenta + | Color::LightCyan + ), + "polarity-unsafe color for #{r:02x}{g:02x}{b:02x}: {c:?}" + ); + } + } + + #[test] + fn polarity_safe_chromatic_buckets() { + assert_eq!(polarity_safe_syntax_fg(0xf7, 0x76, 0x8e), Color::Red); + assert_eq!(polarity_safe_syntax_fg(0xe0, 0xaf, 0x68), Color::Yellow); + assert_eq!(polarity_safe_syntax_fg(0x9e, 0xce, 0x6a), Color::Yellow); // lime → yellow bucket + assert_eq!(polarity_safe_syntax_fg(0x7d, 0xcf, 0xff), Color::Cyan); + assert_eq!(polarity_safe_syntax_fg(0x7a, 0xa2, 0xf7), Color::Blue); + assert_eq!(polarity_safe_syntax_fg(0xbb, 0x9a, 0xf7), Color::Magenta); + } + + #[test] + fn syntect_rgb_to_fg_uses_polarity_safe_when_locked() { + with_native_lock(true, || { + // Pastel that naive quantize would turn White. + assert_eq!(syntect_rgb_to_fg(0xc8, 0xc8, 0xc8), Color::Reset); + assert_eq!(syntect_rgb_to_fg(0xbb, 0x9a, 0xf7), Color::Magenta); + }); + } + + #[test] + fn highlight_line_fallback_when_no_highlighter() { + let syn = get_syntect(); + let mut hl = None; + let fallback = Style::default().fg(Color::Reset); + let spans = highlight_line("fn main() {}", &mut hl, syn, fallback); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content.as_ref(), "fn main() {}"); + assert_eq!(spans[0].style.fg, Some(Color::Reset)); + } + + #[test] + fn highlight_line_under_native_lock_avoids_white_tokens() { + with_native_lock(true, || { + let syn = get_syntect(); + let mut hl = syn.highlight_lines_for_token("rust"); + let fallback = Style::default().fg(Color::Reset); + let spans = highlight_line("fn main() { let x = 1; /* c */ }", &mut hl, syn, fallback); + assert!(!spans.is_empty()); + for span in &spans { + let fg = span.style.fg; + assert!( + !matches!(fg, Some(Color::White)), + "token {:?} painted White under native lock", + span.content + ); + } + }); + } +} diff --git a/crates/codegen/xai-grok-pager-render/src/theme/cache.rs b/crates/codegen/xai-grok-pager-render/src/theme/cache.rs index 0c65472..a1b23e0 100644 --- a/crates/codegen/xai-grok-pager-render/src/theme/cache.rs +++ b/crates/codegen/xai-grok-pager-render/src/theme/cache.rs @@ -113,11 +113,16 @@ pub fn terminal_native_locked() -> bool { /// Engage or clear the terminal-native theme lock. pub fn set_terminal_native_lock(locked: bool) { TERMINAL_NATIVE_LOCK.store(locked, Ordering::Relaxed); + // Cap quantization at ANSI-16 and switch syntax tokens to the dual- + // polarity accent map (default-fg grays + base ANSI hues). Without the + // polarity-safe remap, night-theme pastels collapse to White and vanish + // on light terminal profiles in minimal mode. xai_grok_markdown::set_color_level_cap(if locked { xai_grok_markdown::ColorLevel::Basic } else { xai_grok_markdown::ColorLevel::TrueColor }); + xai_grok_markdown::set_polarity_safe_syntax(locked); } // -- Auto-mode --------------------------------------------------------------- @@ -384,6 +389,20 @@ mod tests { }); } + #[test] + fn terminal_native_lock_enables_polarity_safe_syntax() { + with_test_env(|| { + assert!(!xai_grok_markdown::polarity_safe_syntax()); + set_terminal_native_lock(true); + assert!( + xai_grok_markdown::polarity_safe_syntax(), + "minimal must engage polarity-safe syntax remapping" + ); + set_terminal_native_lock(false); + assert!(!xai_grok_markdown::polarity_safe_syntax()); + }); + } + #[test] fn terminal_native_lock_caps_quantize_at_ansi16() { use ratatui::style::Color; diff --git a/crates/codegen/xai-grok-pager-render/src/theme/terminal_default.rs b/crates/codegen/xai-grok-pager-render/src/theme/terminal_default.rs index caffc0d..286c775 100644 --- a/crates/codegen/xai-grok-pager-render/src/theme/terminal_default.rs +++ b/crates/codegen/xai-grok-pager-render/src/theme/terminal_default.rs @@ -22,6 +22,10 @@ //! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM` //! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike //! hard-coding bright black. +//! - **Syntax highlighting** is not themed day/night. Under the native lock, +//! syntect tokens are remapped via +//! [`crate::syntax::polarity_safe_syntax_fg`] (default-fg grays + base ANSI +//! accents). Do not load a light tmTheme based on OS/terminal detection. use ratatui::style::{Color, Modifier}; diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index ee842a1..3533fb6 100644 --- a/crates/codegen/xai-grok-pager/Cargo.toml +++ b/crates/codegen/xai-grok-pager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager" -version = "0.2.105" +version = "0.2.106" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager/docs/custom-hooks.md b/crates/codegen/xai-grok-pager/docs/custom-hooks.md index c0176b3..142f75e 100644 --- a/crates/codegen/xai-grok-pager/docs/custom-hooks.md +++ b/crates/codegen/xai-grok-pager/docs/custom-hooks.md @@ -84,10 +84,10 @@ Each `.json` file can define multiple hooks: Key fields: - **Event name** (top-level key): `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`, etc. -- **matcher** (optional): Regex that must match the tool name. Only applies to `PreToolUse`/`PostToolUse`. Empty = match everything. +- **matcher** (optional): Regex tested against the event's match value — the tool name on tool events, and per-event values elsewhere (see the user guide's Hooks chapter). Empty = match everything. - **type**: `"command"` (run a script or shell one-liner) or `"http"` (POST the event to a URL). - **command**: Path to executable (relative to the JSON file) or inline shell command. -- **timeout**: Seconds before killing the hook (default: 5). Hooks fail open on timeout. +- **timeout**: Seconds before killing the hook (default: 5, or 600 for `Stop`/`SubagentStop` gates). Hooks fail open on timeout. **Tool name aliases**: Claude-style names like `Bash`, `Edit`, `Read` automatically match Grok's internal names (`run_terminal_cmd`, `search_replace`, `read_file`). @@ -116,7 +116,7 @@ Write JSON to **stdout**: **Exit codes** (behavior differs by hook type): - `0` — success / allow (for blocking hooks) -- `2` — explicit deny (blocking hooks only) +- `2` — explicit deny (`PreToolUse`) or block-stop with stderr as feedback (`Stop`/`SubagentStop`; see Stop Decision Control in the user guide) - Any other (including timeout/crash/missing env var) — **fail-open**: the failure is logged and shown in the hook scrollback, but the tool call is not blocked. To block a tool call, return JSON `{"decision":"deny","reason":"..."}` on stdout. ### Passive hooks diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md index 360aa69..6c21e2b 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md @@ -77,11 +77,14 @@ Rewind the conversation to an earlier turn, discarding everything after it. ### `/copy` -Copy the most recent response to the clipboard. Pass a number to copy the Nth-latest response. +Copy the most recent response to the clipboard. Pass a number to copy the Nth-latest response. Pass a file path to write instead of using the clipboard (useful over SSH when the local clipboard is unreachable). +Every copy is also written to a backup file (`~/.grok/last-copy.txt` by default, or `GROK_COPY_FILE` if set), and the toast names that path so you always know where to retrieve the text — including when the clipboard cannot be reached (for example Apple Terminal over SSH) or the copy went out as an OSC 52 escape that this terminal couldn't be verified to apply. ``` /copy /copy 2 +/copy out.txt +/copy 2 ~/exports/last-reply.md ``` ### `/export` diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/06-theming.md b/crates/codegen/xai-grok-pager/docs/user-guide/06-theming.md index 864617c..27cda25 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/06-theming.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/06-theming.md @@ -22,6 +22,8 @@ Theme names are case-insensitive. The `auto` option (alias `system`) is document **Minimal mode** (`--minimal`) always renders with a single fixed terminal-native palette and ignores the `theme` settings entirely (they still apply to the full TUI). Minimal draws directly on your terminal's own background, so it uses your terminal's default foreground/background plus its 16-color ANSI palette — the same colors `git` or `ls` use — which stays readable on any light or dark terminal profile without detection or configuration. `/theme` and the theme rows in `/settings` are unavailable in minimal mode. +Syntax highlighting in minimal mode does **not** switch between light and dark theme files (polarity detection is intentionally avoided). Near-gray tokens inherit the terminal default foreground; chromatic tokens use base ANSI accents (red/green/yellow/blue/magenta/cyan) so read-file output and fenced code stay legible on both light and dark profiles. + --- ## Switching Themes diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md b/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md index e6d67d7..af9946e 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md @@ -9,6 +9,7 @@ Hooks let you run a script or send an HTTP request at key moments in a Grok sess A hook is a shell command or HTTP endpoint that Grok calls when a specific lifecycle event occurs. Hooks can: - **Block actions** -- A `PreToolUse` hook can deny a dangerous command before it runs. +- **Keep the agent working** -- A `Stop` hook can block the agent from finishing its turn until a condition holds (e.g. the test suite passes) and feed the reason back to the model. - **React to events** -- A `PostToolUse` hook can log every tool execution to a file. - **Set up context** -- A `SessionStart` hook can export environment variables or run setup scripts. @@ -87,16 +88,16 @@ Because hooks are unified under folder-trust, a `--trust` / `/hooks-trust` grant | `PostToolUse` | A tool completes successfully. | No | | `PostToolUseFailure` | A tool fails. | No | | `PermissionDenied` | The permission system denies a tool call. | No | -| `Stop` | An agent turn ends (completed, cancelled, or error). | No | +| `Stop` | An agent turn ends on a genuine completion (not on a user interrupt). | Yes — can block the stop | | `StopFailure` | A turn ends because of an API error. | No | | `Notification` | The agent sends a notification. | No | | `SubagentStart` | A subagent starts. | No | -| `SubagentStop` | A subagent finishes. | No | +| `SubagentStop` | A subagent's turn ends (fires once, in the subagent, with stop decision control). | Yes — can block the stop | | `PreCompact` | Conversation compaction is about to run. | No | | `PostCompact` | Conversation compaction completes. | No | | `SessionEnd` | The session ends. | No | -`SubagentEnd` is accepted as an alias for `SubagentStop`. Only `PreToolUse` can block a tool call; every other event is passive. +`SubagentEnd` is accepted as an alias for `SubagentStop`. `PreToolUse` can block a tool call, and `Stop`/`SubagentStop` can block the agent from stopping (see [Stop Decision Control](#stop-decision-control)); every other event is passive. ### Cursor Hook Compatibility @@ -146,10 +147,10 @@ Each `.json` file can define hooks for multiple events: ### Key Fields - **Event name** (top-level key): any event listed in [Hook Events](#hook-events). Grok skips unrecognized event names so a shared Claude or Cursor settings file still loads. -- **matcher** (optional): A regular expression that selects which invocations trigger the hook. It applies to the tool events — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, and `PermissionDenied` — where it tests the tool name, and to `Notification`, where it tests the notification type. The lifecycle events (`SessionStart`, `SessionEnd`, `Stop`, `UserPromptSubmit`) reject a matcher; other events ignore it. An empty or omitted matcher matches everything. The matcher tests the real tool name; MCP calls routed through the internal `use_tool` dispatcher appear as the qualified `server__tool` name (e.g. `linear__save_issue`), so match on that, not the dispatcher name. +- **matcher** (optional): A regular expression that selects which invocations trigger the hook. What it tests depends on the event: the tool name on tool events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`), the notification type on `Notification`, the subagent type on `SubagentStart`/`SubagentStop` (e.g. `explore`), the start source on `SessionStart` (`startup`, `resume`, …), the end reason on `SessionEnd`, the compaction trigger on `PreCompact`/`PostCompact` (`manual` or `auto`), and the error type on `StopFailure` (`rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown`). A matcher on `Stop` or `UserPromptSubmit` is ignored with a warning (those events always fire). An empty or omitted matcher matches everything. The matcher tests the real tool name; MCP calls routed through the internal `use_tool` dispatcher appear as the qualified `server__tool` name (e.g. `linear__save_issue`), so match on that, not the dispatcher name. - **type**: `"command"` (run a script or shell one-liner) or `"http"` (POST the event to a URL). - **command**: Path to executable (relative to the JSON file) or inline shell command. -- **timeout**: Seconds before killing the hook (default: 5). All hook failures (timeouts, crashes, malformed output, missing required env vars) are fail-open: the failure is recorded for the UI scrollback but the tool call is not blocked. Only an explicit `deny` decision returned by the hook blocks a tool call. +- **timeout**: Seconds before killing the hook (default: 5, or 600 for `Stop`/`SubagentStop` gates, matching Claude Code). All hook failures (timeouts, crashes, malformed output, missing required env vars) are fail-open: the failure is recorded for the UI scrollback but the tool call is not blocked. Only an explicit `deny` decision returned by the hook blocks a tool call. ### Tool Name Aliases @@ -179,12 +180,15 @@ The event is sent as JSON on **stdin** (for example, a `PreToolUse` event; the p "sessionId": "abc-123", "cwd": "/Users/you/project", "workspaceRoot": "/Users/you/project", + "permissionMode": "default", "toolName": "run_terminal_command", "toolInput": { "command": "npm test" }, "timestamp": "2026-04-14T12:00:00Z" } ``` +Every event carries the same common fields: `hookEventName`, `sessionId`, `cwd`, `workspaceRoot`, `timestamp`, and `permissionMode` (`default`, `auto`, `plan`, or `bypassPermissions`), plus event-specific fields like `toolName` above. + ### Output (Blocking Hooks) For `PreToolUse` hooks, write JSON to **stdout**: @@ -197,8 +201,59 @@ For `PreToolUse` hooks, write JSON to **stdout**: | Exit Code | Meaning | |-----------|---------| | `0` | Success / allow (for blocking hooks) | -| `2` | Explicit deny (blocking hooks only) | -| Other | Fail-open — the failure is recorded but the tool call is not blocked. To block a call, emit a `deny` decision in stdout JSON (honored regardless of exit code). | +| `2` | Explicit deny (`PreToolUse`) or block-stop with stderr as feedback (`Stop`/`SubagentStop`) | +| Other | Fail-open — the failure is recorded but nothing is blocked. For `PreToolUse`, a `deny` decision in stdout JSON is honored regardless of exit code. For `Stop`/`SubagentStop`, a valid decision JSON on stdout wins over the exit code (matching Claude Code); the exit code decides only when stdout has no usable JSON, in which case exit 2 blocks with stderr as the feedback. | + +### Stop Decision Control + +`Stop` and `SubagentStop` hooks run when the agent is about to finish its turn and can keep it working (Claude Code-compatible). Write JSON to **stdout**: + +- **Block the stop**: `{"decision": "block", "reason": "The test suite hasn't been run yet"}`. The reason is fed back to the model as a user message and the agent runs another round in the same turn. +- **Non-error feedback**: `{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": "Run the linter before finishing"}}`. Also keeps the agent working, but is surfaced as hook feedback rather than a hook error. +- **Force stop**: `{"continue": false, "stopReason": "Budget exhausted"}`. Ends the turn, overriding any blocks. +- **Allow the stop**: exit 0 with no output (or any non-JSON output). + +Exiting with code `2` also blocks the stop, with **stderr** as the feedback. + +The hook input includes `stopHookActive` and `lastAssistantMessage`. `stopHookActive` is true when the agent is already continuing due to a previous stop-hook block this turn; check it, or the transcript, to avoid blocking on a condition that will never resolve. `lastAssistantMessage` carries the text of the agent's final response this turn, so hooks can act on it without parsing the transcript. After **8 continuations** (blocks or non-error feedback) in one turn the gate is overridden and the turn ends; hooks are not consulted for that final, forced stop. The counter is per turn: the next user prompt starts fresh, so a long-running goal can span turns. Hook failures fail open: the agent stops normally. + +`Stop` and `SubagentStop` hooks default to a 600-second timeout (matching Claude Code) because gates commonly run builds or test suites, and a timed-out hook fails open, so the agent stops anyway. Other events keep the 5-second default. Set `timeout` explicitly when a gate needs more: `{ "type": "command", "command": "bin/verify.sh", "timeout": 1200 }`. + +The gate runs only for genuine completions. Interrupted (Esc / Ctrl+C), refused, and max-turns turns skip Stop hooks entirely, and API-error turns fire `StopFailure` instead. A separate Stop also fires at session end (`reason: "channel_closed"` or `"shutdown"`); its decision output is parsed but ignored, since there is no turn left to continue. A script that counts or gates on Stop fires should check `reason == "end_turn"` so the session-end fire doesn't skew it. + +`StopFailure` is observation-only (use it to log failures or send alerts; output and exit code are ignored). Its input carries `error` (the classified type the matcher tests, in Claude Code's vocabulary: `rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown` for anything the runtime cannot distinguish; capacity errors fold into `rate_limit` and there is no signal for `billing_error`), `errorDetails` (the raw error detail, when available), and `lastAssistantMessage` (the rendered error text shown in the conversation; for this event it is the error string, not assistant output). + +`Stop` input also carries `backgroundTasks` and `sessionCrons`, so a hook can distinguish "session is done" from "session is paused waiting for background work to wake it back up". Both arrays are empty when nothing is in flight or scheduled. Each `backgroundTasks` entry describes one in-flight task: `id`, `type` (`shell`, `monitor`, or `subagent`), `status`, and (depending on the type) `command` (shell tasks only), `description` (a monitor's watched command line, or a subagent's task description), and `agentType` (subagents). Each `sessionCrons` entry describes one scheduled wakeup (`scheduler_create` or `/loop`): `id`, `schedule`, `recurring`, and `prompt`. The `schedule` value is a human-readable interval such as `every 5 minutes`; grok schedules are intervals, not cron expressions. Free-text entry fields are capped at 1000 characters with an in-string `… [+N chars]` marker. + +Inside a subagent, the gate fires as `SubagentStop` (agent-frontmatter `Stop` hooks are automatically remapped). A `Stop` hook only gates the main agent. + +`SubagentStop` fires once per subagent, at the subagent's own turn end, matching Claude Code. Its input carries a `phase` field (currently always `"gate"`) reserved for forward compatibility. + +**Porting Claude Code stop hooks**: the output vocabulary (`decision`, `reason`, `continue`, `stopReason`, `additionalContext`) works unchanged. Check this list for what does not match Claude: + +- **camelCase input**: grok's stdin envelope uses camelCase keys throughout where Claude uses snake_case. A script reading `.stop_hook_active`, `.hook_event_name`, or `.background_tasks[].agent_type` must switch to `.stopHookActive`, `.hookEventName`, and `.backgroundTasks[].agentType` (the event value is `"stop"`). Hooks registered through the grok-agent-sdk convert both the top-level keys and the `backgroundTasks`/`sessionCrons` entry keys to snake_case, so the wire's `.backgroundTasks[].agentType` reads as `.background_tasks[].agent_type` in the SDK. +- **`toolResult` field**: the `PostToolUse` tool output is `toolResult` (SDK: `tool_result`), not Claude's `tool_response`; a hook reading `.tool_response` must switch to `.toolResult`. +- **Session-end fire**: an extra observe-only Stop fires at session end; filter on `reason == "end_turn"` (see above). +- **Interval schedules**: `sessionCrons[].schedule` is a human-readable interval, never a cron expression. +- **Task types**: `backgroundTasks[].type` is only `shell`, `monitor`, or `subagent`; Claude's other labels (`workflow`, `teammate`, …) are not emitted. +- **StopFailure classes**: the emitted set is Claude Code's vocabulary — `rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, `unknown`. grok emits a subset: capacity errors (503/529) fold into `rate_limit` as in Claude, and `billing_error` is never emitted (no signal), so a `billing_error` matcher will not fire. +- **permission_mode values**: grok emits `default`, `auto`, `plan`, or `bypassPermissions`. Claude's `acceptEdits`/`dontAsk` have no grok equivalent (grok's `auto` is the nearest), so a check like `permission_mode === "acceptEdits"` never matches. +- **Client (SDK) gate timeouts**: SDK `Stop`/`SubagentStop` gates default to 600 seconds like file hooks; `PreToolUse` client gates default to 30 seconds (the interactive hot path). Either can be overridden per matcher group via `timeoutS`, capped at 600. +- **`/goal`**: grok's goal loop is a separate feature that runs before the stop gate; it is not a prompt-type Stop hook. + +A complete keep-working policy in one script: + +```bash +#!/bin/bash +input=$(cat) +# Gate only genuine turn ends, not the session-end observe fire. +if [ "$(echo "$input" | jq -r '.reason')" != "end_turn" ]; then exit 0; fi +if ! bin/verify.sh >/dev/null 2>&1; then + echo '{"decision": "block", "reason": "verify.sh failed; fix the failures before finishing"}' +fi +``` + +registered as `{ "type": "command", "command": "bin/stop-gate.sh", "timeout": 300 }` with `timeout` sized for the verify step. The hook fires again after each continuation, and the built-in cap ends the turn after 8; check `stopHookActive` to give up earlier on feedback the agent evidently cannot act on. ### Passive Hooks diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md index 4b311bb..badb9ad 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md @@ -92,6 +92,8 @@ Grok writes to the clipboard through up to three routes, shown in the **Clipboar **Linux Wayland**: on compositors that support the data-control protocol (GNOME 48+, KDE, Sway, Hyprland — the **Clipboard** section shows `data-control on`; the line is omitted off Wayland) copies work even if the terminal loses focus mid-copy. On older compositors (GNOME 46/47), keep the terminal focused until the copy toast confirms, and install the `wl-clipboard` package (provides `wl-copy`) for the most reliable route — Grok shows a startup warning when this applies. If data-control misbehaves on your compositor, set `GROK_CLIPBOARD_NO_DATA_CONTROL=1` to stop Grok from speaking that protocol entirely — copies then go through the CLI tools (`wl-copy`/`xclip`). +**OSC 52 kill switch**: Grok emits OSC 52 on every Linux copy (and over SSH/tmux/containers). Terminals that do not implement OSC 52 may paint the base64 payload as visible garbage (for example some VNC/X11 clients such as OpenText Exceed). Set `GROK_CLIPBOARD_NO_OSC52=1` before starting Grok to force the OSC 52 leg off; `/terminal-setup` then shows `osc 52 off`. Native and tmux clipboard legs are unchanged. + **Linux X11 selections**: X11 **PRIMARY** and **CLIPBOARD** are separate. Selecting text usually fills PRIMARY; an explicit Copy action fills CLIPBOARD. In Grok: - An unmodified middle click reads PRIMARY only when `DISPLAY` is non-empty. Pure X11 can fall back to the native arboard reader. XWayland must have `xclip` or `xsel` on `PATH`; Grok deliberately disables the arboard fallback there so it cannot substitute Wayland PRIMARY. @@ -103,9 +105,9 @@ Grok writes to the clipboard through up to three routes, shown in the **Clipboar **Unknown terminals over SSH**: when Grok cannot identify the outer terminal, it sends the copy but reports delivery as unverified. If paste fails, reconnect with `grok wrap ` or use `/minimal`. **Known limitation — Apple Terminal + SSH**: -Apple Terminal ignores OSC 52, so copying from a Grok session over SSH can't reach your local clipboard. Use the workaround below. +Apple Terminal ignores OSC 52, so copying from a Grok session over SSH can't reach your local clipboard. Grok writes every in-app copy to a backup file (`~/.grok/last-copy.txt`, override with `GROK_COPY_FILE`) and the toast names the path — so you can `cat`/`scp` it. You can also target a file explicitly with `/copy out.txt` or `/copy 2 ~/reply.md`. For native drag-select copy (terminal selection → local clipboard), turn mouse capture off with `/toggle-mouse-reporting` (opt-in feature) or run `grok --minimal`. -**Temporary workaround**: Use `grok wrap ssh` instead of plain `ssh` (for example, `grok wrap ssh user@host`). It runs the command in a local PTY that intercepts OSC 52 sequences, including tmux-wrapped ones, and writes their contents to your local clipboard. The same command wraps anything else whose clipboard can't reach you — for example `grok wrap docker exec -it bash` or `grok wrap kubectl exec -it -- bash`. +**Optional workaround for live clipboard**: Use `grok wrap ssh` instead of plain `ssh` (for example, `grok wrap ssh user@host`). It runs the command in a local PTY that intercepts OSC 52 sequences, including tmux-wrapped ones, and writes their contents to your local clipboard. The same command wraps anything else whose clipboard can't reach you — for example `grok wrap docker exec -it bash` or `grok wrap kubectl exec -it -- bash`. `grok wrap` also protects your local terminal from dirty disconnects: if the wrapped command dies while a remote TUI has mouse reporting, the alternate screen, or similar modes enabled (for example the SSH connection drops mid-session), wrap resets those modes on exit instead of leaving the terminal spraying mouse escape codes. diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs index 99bdbbb..f03b4cf 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs @@ -313,18 +313,25 @@ pub(super) fn handle_scheduled_task_created( .scheduled_tasks .retain(|k, _| !k.starts_with("provisional-")); - agent - .session - .scheduled_tasks - .entry(task_id.clone()) - .or_insert_with(|| crate::app::agent::ScheduledTaskInfo { - task_id, - prompt, - human_schedule, - created_at: std::time::Instant::now(), - next_fire_at, - tag: "loop".into(), - }); + match agent.session.scheduled_tasks.entry(task_id.clone()) { + Entry::Occupied(mut e) => { + let info = e.get_mut(); + info.prompt = prompt; + info.human_schedule = human_schedule; + info.next_fire_at = next_fire_at; + } + Entry::Vacant(e) => { + e.insert(crate::app::agent::ScheduledTaskInfo { + task_id, + prompt, + human_schedule, + created_at: std::time::Instant::now(), + next_fire_at, + tag: "loop".into(), + last_subagent_id: None, + }); + } + } is_active } @@ -333,13 +340,14 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu let Ok(session_notif) = serde_json::from_str::(notif.params.get()) else { return false; }; - let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update { + let (task_id, prompt, human_schedule, next_fire_at, subagent_id) = match session_notif.update { XaiSessionUpdate::ScheduledTaskFired { task_id, prompt, human_schedule, next_fire_at, - } => (task_id, prompt, human_schedule, next_fire_at), + subagent_id, + } => (task_id, prompt, human_schedule, next_fire_at, subagent_id), _ => return false, }; let matched = match find_session_match(app, &session_notif.session_id) { @@ -358,12 +366,13 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu // payload so the tasks pane still shows the loop. match agent.session.scheduled_tasks.entry(task_id) { Entry::Occupied(mut e) => { - e.get_mut().next_fire_at = next_fire_at; + let info = e.get_mut(); + info.next_fire_at = next_fire_at; + if subagent_id.is_some() { + info.last_subagent_id = subagent_id; + } } Entry::Vacant(e) => { - // next_fire_at: None marks a missed-one-shot fire from - // handle_missed_tasks(); a ScheduledTaskRemoved follows - // immediately. Skip the insert to avoid a one-frame flicker. if next_fire_at.is_none() { return is_active; } @@ -375,6 +384,7 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu created_at: std::time::Instant::now(), next_fire_at, tag: "loop".into(), + last_subagent_id: subagent_id, }); } } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs index 5525a70..49cb7ed 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs @@ -627,6 +627,15 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu xai_grok_shell::extensions::notification::HookRunStatusDto::Failed { error, elapsed_ms, + blocked: true, + } => HookRunStatus::Blocked { + detail: error, + elapsed: std::time::Duration::from_millis(elapsed_ms), + }, + xai_grok_shell::extensions::notification::HookRunStatusDto::Failed { + error, + elapsed_ms, + blocked: false, } => HookRunStatus::Failed { error, elapsed: std::time::Duration::from_millis(elapsed_ms), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs index 1da0c7e..f271ec2 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs @@ -511,6 +511,26 @@ pub(super) fn make_fired_notif( prompt: prompt.into(), human_schedule: human_schedule.into(), next_fire_at: next_fire_at.map(str::to_string), + subagent_id: None, + }, + meta: None, + }; + let raw = serde_json::value::to_raw_value(¬if).unwrap(); + acp::ExtNotification::new("x.ai/scheduled_task_fired", std::sync::Arc::from(raw)) +} +pub(super) fn make_fired_notif_with_subagent( + session_id: &str, + task_id: &str, + subagent_id: &str, +) -> acp::ExtNotification { + let notif = SessionNotification { + session_id: acp::SessionId::new(session_id), + update: XaiSessionUpdate::ScheduledTaskFired { + task_id: task_id.into(), + prompt: "p".into(), + human_schedule: "every 1 minute".into(), + next_fire_at: Some("2026-02-02T02:02:02Z".into()), + subagent_id: Some(subagent_id.into()), }, meta: None, }; @@ -941,16 +961,31 @@ pub(super) fn xai_hook_execution_notif_for_prompt( is_replay: bool, ) -> acp::ExtNotification { use xai_grok_shell::extensions::notification::{HookRunEntryDto, HookRunStatusDto}; + xai_hook_execution_notif_with_runs( + session_id, + event_name, + prompt_id, + is_replay, + vec![ + HookRunEntryDto { name : "global/notify".into(), status : + HookRunStatusDto::Success { elapsed_ms : 12 }, output : None, } + ], + ) +} +pub(super) fn xai_hook_execution_notif_with_runs( + session_id: &str, + event_name: &str, + prompt_id: Option<&str>, + is_replay: bool, + runs: Vec, +) -> acp::ExtNotification { let payload = SessionNotification { session_id: acp::SessionId::new(session_id), update: XaiSessionUpdate::HookExecution { event_name: event_name.into(), tool_name: None, prompt_id: prompt_id.map(str::to_string), - runs: vec![ - HookRunEntryDto { name : "global/notify".into(), status : - HookRunStatusDto::Success { elapsed_ms : 12 }, output : None, } - ], + runs, }, meta: Some(serde_json::json!({ "isReplay" : is_replay })), }; diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/scheduled_tasks.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/scheduled_tasks.rs index ff11739..6f297d4 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/scheduled_tasks.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/scheduled_tasks.rs @@ -264,6 +264,7 @@ created_at: original_created_at, next_fire_at: Some("2026-01-01T00:00:00Z".into()), tag: "loop".into(), + last_subagent_id: None, }, ); } @@ -365,6 +366,7 @@ created_at: Instant::now(), next_fire_at: Some("2026-01-01T00:00:00Z".into()), tag: "loop".into(), + last_subagent_id: None, }, ); } @@ -391,6 +393,7 @@ created_at: Instant::now(), next_fire_at: Some("2026-01-01T00:00:00Z".into()), tag: "loop".into(), + last_subagent_id: None, }, ); } @@ -428,6 +431,85 @@ ); } + #[test] + fn fired_with_subagent_id_links_chip_and_survives_foreground_fire() { + let mut app = make_app_with_agent("sess-1"); + + let notif = make_fired_notif_with_subagent("sess-1", "task-bg", "sub-abc"); + assert!(handle_scheduled_task_fired(¬if, &mut app)); + { + let agent = app.agents.get(&AgentId(0)).unwrap(); + let info = agent.session.scheduled_tasks.get("task-bg").unwrap(); + assert_eq!(info.last_subagent_id.as_deref(), Some("sub-abc")); + } + + let notif = make_fired_notif_with_subagent("sess-1", "task-bg", "sub-def"); + assert!(handle_scheduled_task_fired(¬if, &mut app)); + { + let agent = app.agents.get(&AgentId(0)).unwrap(); + let info = agent.session.scheduled_tasks.get("task-bg").unwrap(); + assert_eq!(info.last_subagent_id.as_deref(), Some("sub-def")); + } + + let notif = make_fired_notif( + "sess-1", + "task-bg", + "p", + "every 1 minute", + Some("2026-03-03T03:03:03Z"), + ); + assert!(handle_scheduled_task_fired(¬if, &mut app)); + let agent = app.agents.get(&AgentId(0)).unwrap(); + let info = agent.session.scheduled_tasks.get("task-bg").unwrap(); + assert_eq!(info.last_subagent_id.as_deref(), Some("sub-def")); + } + + #[test] + fn created_upserts_existing_chip_preserving_identity_and_linkage() { + let mut app = make_app_with_agent("sess-1"); + let original_created_at = Instant::now() - std::time::Duration::from_secs(60); + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.scheduled_tasks.insert( + "task-up".into(), + crate::app::agent::ScheduledTaskInfo { + task_id: "task-up".into(), + prompt: "old prompt".into(), + human_schedule: "every 5 minutes".into(), + created_at: original_created_at, + next_fire_at: Some("2026-01-01T00:00:00Z".into()), + tag: "loop".into(), + last_subagent_id: Some("sub-abc".into()), + }, + ); + } + + let notif = make_created_ext_notif( + "sess-1", + "task-up", + "new prompt", + "every 10 minutes", + Some("2026-02-02T02:02:02Z"), + ); + assert!(handle_scheduled_task_created(¬if, &mut app)); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!(agent.session.scheduled_tasks.len(), 1, "no duplicate chip"); + let info = agent.session.scheduled_tasks.get("task-up").unwrap(); + assert_eq!(info.prompt, "new prompt"); + assert_eq!(info.human_schedule, "every 10 minutes"); + assert_eq!(info.next_fire_at.as_deref(), Some("2026-02-02T02:02:02Z")); + assert_eq!( + info.created_at, original_created_at, + "chip identity (countdown anchor) preserved" + ); + assert_eq!( + info.last_subagent_id.as_deref(), + Some("sub-abc"), + "click-through linkage preserved across an update" + ); + } + #[test] fn created_updates_correct_agent_when_active_view_differs() { let mut app = make_app_two_agents(); @@ -472,6 +554,7 @@ created_at: Instant::now(), next_fire_at: Some("2026-01-01T00:00:00Z".into()), tag: "loop".into(), + last_subagent_id: None, }, ); } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs index c16718a..797972a 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs @@ -529,6 +529,69 @@ assert!(agent.pending_stop_hooks.is_none()); } + /// The wire `blocked` flag splits a failed run: a stop-gate block maps to + /// `HookRunStatus::Blocked` (a decision, not a failure), a plain failure stays `Failed`. + #[test] + fn blocked_wire_flag_maps_to_blocked_status() { + use crate::scrollback::blocks::tool::HookRunStatus; + use xai_grok_shell::extensions::notification::{HookRunEntryDto, HookRunStatusDto}; + + let mut app = make_app_with_agent("sess-blocked"); + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.start_turn(&mut agent.scrollback); + agent.session.current_prompt_id = Some("pid-1".into()); + } + + let _ = handle_ext_notification( + &xai_hook_execution_notif_with_runs( + "sess-blocked", + "stop", + Some("pid-1"), + false, + vec![ + HookRunEntryDto { + name: "gate".into(), + status: HookRunStatusDto::Failed { + error: "blocked stop: run the tests".into(), + elapsed_ms: 7, + blocked: true, + }, + output: None, + }, + HookRunEntryDto { + name: "broken".into(), + status: HookRunStatusDto::Failed { + error: "exit code 1".into(), + elapsed_ms: 3, + blocked: false, + }, + output: None, + }, + ], + ), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + let pending = agent + .pending_stop_hooks + .as_ref() + .expect("stop hooks must be stashed for the marker"); + let runs = &pending.groups[0].1; + assert!( + matches!(&runs[0].status, HookRunStatus::Blocked { detail, .. } + if detail == "blocked stop: run the tests"), + "blocked: true must map to Blocked, got {:?}", + runs[0].status + ); + assert!( + matches!(&runs[1].status, HookRunStatus::Failed { .. }), + "blocked: false must stay Failed, got {:?}", + runs[1].status + ); + } + #[test] fn foreign_turn_stop_hooks_never_stash_under_running_turn() { // A delayed batch from an ended turn (pid-old) lands while a later diff --git a/crates/codegen/xai-grok-pager/src/app/actions.rs b/crates/codegen/xai-grok-pager/src/app/actions.rs index 90f3f09..1b9c31a 100644 --- a/crates/codegen/xai-grok-pager/src/app/actions.rs +++ b/crates/codegen/xai-grok-pager/src/app/actions.rs @@ -307,9 +307,11 @@ pub enum Action { ShowDebugStatus, /// Copy selected block's content to clipboard. CopyBlockContent, - /// Copy the Nth most recent assistant message to clipboard (1 = latest). + /// Copy the Nth most recent assistant message (1 = latest). + /// `None` => clipboard (with file fallback on failure); `Some(p)` => write UTF-8 file. CopyAssistantMessage { n: usize, + file_path: Option, }, /// Export the active (sub)agent's conversation transcript as Markdown. /// `None` => copy to clipboard (with route-aware toast + stats); `Some(p)` => write UTF-8 file diff --git a/crates/codegen/xai-grok-pager/src/app/agent.rs b/crates/codegen/xai-grok-pager/src/app/agent.rs index 900cf03..53658e0 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent.rs @@ -285,6 +285,7 @@ pub struct ScheduledTaskInfo { pub next_fire_at: Option, /// Tag shown in the tasks pane (e.g. "loop", "check"). pub tag: String, + pub last_subagent_id: Option, } /// Parsed goal status from `GoalUpdated` session notifications. /// diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs index 122a4e7..e74c498 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs @@ -533,7 +533,7 @@ impl AgentView { use crate::scrollback::blocks::mermaid_content::AffordanceKind; match kind { AffordanceKind::CopySource => { - if self.copy_to_clipboard(&source).is_failed() { + if !self.copy_to_clipboard(&source).success() { crate::unified_log::error( "mermaid.copy_source.failed", self.session.session_id.as_ref().map(|s| s.0.as_ref()), diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs index 9ccf77e..5270b23 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs @@ -269,11 +269,18 @@ impl AgentView { false } - /// Copy text to clipboard and show the result toast. - pub fn copy_to_clipboard(&mut self, text: &str) -> crate::clipboard::ClipboardDelivery { - let result = crate::clipboard::copy_text(text); - self.show_toast_ticks(result.message, result.ticks); - result.delivery + /// Copy text to clipboard (a backup file is always written too — see + /// `copy_text_or_file`) and show the result toast. + /// + /// When every trusted clipboard backend fails (common on Apple Terminal + /// over SSH), the toast points at the backup file + /// (`~/.grok/last-copy.txt`, or `GROK_COPY_FILE`) instead. The returned + /// [`CopyDelivery`](crate::clipboard::CopyDelivery) tells callers where + /// the copy actually landed (clipboard, backup file, or nowhere). + pub fn copy_to_clipboard(&mut self, text: &str) -> crate::clipboard::CopyDelivery { + let delivery = crate::clipboard::copy_text_or_file(text); + self.show_toast_ticks(delivery.toast_message().as_ref(), delivery.toast_ticks()); + delivery } /// Like [`copy_to_clipboard`] but debounces the toast to prevent @@ -284,8 +291,8 @@ impl AgentView { .last_clipboard_toast_at .is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS); if too_soon { - // Still copy, just skip the toast. - let _ = crate::clipboard::copy_text(text); + // Still deliver (clipboard or file fallback), just skip the toast. + let _ = crate::clipboard::copy_text_or_file(text); return; } self.last_clipboard_toast_at = Some(now); diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs index a454125..bf1d32e 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs @@ -1617,6 +1617,7 @@ pub(super) mod paste_key_tests { assert!( toast.starts_with("Copied") || toast.starts_with("Copy sent") + || toast.starts_with("Clipboard unreachable") || toast.starts_with("Copy failed"), "copy-source emits a clipboard toast, got {toast:?}", ); diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs index d0b8284..aa55afe 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs @@ -1732,25 +1732,26 @@ impl AgentView { } if let Some(msg) = self.active_toast_message() { let sb = layout.scrollback; - let toast_text = format!(" {msg} "); - let w = toast_text.chars().count() as u16; - if sb.height > 0 && sb.width > w + 2 { - let x = sb.right().saturating_sub(w + 1); - let y = sb.bottom().saturating_sub(1); - for (i, ch) in toast_text.chars().enumerate() { - if let Some(cell) = buf.cell_mut((x + i as u16, y)) { - cell.set_char(ch); - cell.fg = theme.accent_user; - cell.bg = theme.bg_base; - cell.modifier = ratatui::prelude::Modifier::BOLD; + if let Some(toast_text) = fit_toast_text(msg, sb.width) { + let w = toast_text.chars().count() as u16; + if sb.height > 0 { + let x = sb.right().saturating_sub(w + 1); + let y = sb.bottom().saturating_sub(1); + for (i, ch) in toast_text.chars().enumerate() { + if let Some(cell) = buf.cell_mut((x + i as u16, y)) { + cell.set_char(ch); + cell.fg = theme.accent_user; + cell.bg = theme.bg_base; + cell.modifier = ratatui::prelude::Modifier::BOLD; + } } + self.frame_occluder_rects.push(Rect { + x, + y, + width: w, + height: 1, + }); } - self.frame_occluder_rects.push(Rect { - x, - y, - width: w, - height: 1, - }); } } if tasks_height > 0 { @@ -3775,19 +3776,19 @@ impl AgentView { buf.set_string(content_x, status_y, &status, status_style); } } - if let Some(ref msg) = block_viewer_toast { - let toast_text = format!(" {msg} "); - let w = toast_text.len() as u16; - if popup_area.height > 2 && popup_area.width > w + 2 { - let tx = popup_area.right().saturating_sub(w + 2); - let ty = popup_area.bottom().saturating_sub(2); - for (i, ch) in toast_text.chars().enumerate() { - if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) { - cell.set_char(ch); - cell.fg = theme.accent_user; - cell.bg = theme.bg_base; - cell.modifier = ratatui::prelude::Modifier::BOLD; - } + if let Some(ref msg) = block_viewer_toast + && popup_area.height > 2 + && let Some(toast_text) = fit_toast_text(msg, popup_area.width.saturating_sub(1)) + { + let w = toast_text.chars().count() as u16; + let tx = popup_area.right().saturating_sub(w + 2); + let ty = popup_area.bottom().saturating_sub(2); + for (i, ch) in toast_text.chars().enumerate() { + if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) { + cell.set_char(ch); + cell.fg = theme.accent_user; + cell.bg = theme.bg_base; + cell.modifier = ratatui::prelude::Modifier::BOLD; } } } @@ -4212,6 +4213,43 @@ impl AgentView { (cursor, prompt_post_flush) } } +/// Pad `msg` for the toast slot, truncating with a trailing ellipsis when it +/// cannot fit in `avail_width` columns (long clipboard toasts embed backup +/// file paths — dropping the whole toast would hide the copy feedback +/// entirely). Returns `None` only when the slot is too narrow for any text. +fn fit_toast_text(msg: &str, avail_width: u16) -> Option { + let max_msg_chars = (avail_width as usize).saturating_sub(4); + if max_msg_chars == 0 { + return None; + } + let msg_chars = msg.chars().count(); + if msg_chars <= max_msg_chars { + return Some(format!(" {msg} ")); + } + let truncated: String = msg.chars().take(max_msg_chars.saturating_sub(1)).collect(); + Some(format!(" {}… ", truncated.trim_end())) +} +#[cfg(test)] +mod toast_fit_tests { + use super::fit_toast_text; + #[test] + fn short_message_is_padded_untouched() { + assert_eq!(fit_toast_text("Copied!", 40).as_deref(), Some(" Copied! ")); + } + #[test] + fn long_message_truncates_with_ellipsis_instead_of_vanishing() { + let msg = "Copied via OSC 52 — also saved to /tmp/grok-0/last-copy.txt. If paste fails, hold Shift (or Fn) and drag to select & copy natively."; + let fitted = fit_toast_text(msg, 60).expect("must render truncated"); + assert!(fitted.chars().count() <= 58); + assert!(fitted.ends_with("… ")); + assert!(fitted.contains("also saved to")); + } + #[test] + fn zero_width_slot_yields_none() { + assert_eq!(fit_toast_text("Copied!", 4), None); + assert_eq!(fit_toast_text("Copied!", 0), None); + } +} #[cfg(test)] mod selection_state_tests { use super::super::test_fixtures::make_agent; diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs index 0f8201a..85308ee 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -593,6 +593,7 @@ pub(super) fn dispatch_send_prompt_inner( created_at: std::time::Instant::now(), next_fire_at: preview.next_fire_at, tag: preview.tag, + last_subagent_id: None, }, ); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs index 66ef9d6..7412b06 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs @@ -550,8 +550,8 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { dispatch_copy_block_content(app); vec![] } - Action::CopyAssistantMessage { n } => { - dispatch_copy_assistant_message(app, n); + Action::CopyAssistantMessage { n, file_path } => { + dispatch_copy_assistant_message(app, n, file_path); vec![] } Action::ExportConversation { file_path } => { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs index 4eaeada..ae9f944 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs @@ -475,8 +475,8 @@ pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec, +) { with_active_agent(app, |agent| { // Collect agent messages in reverse order (most recent first). let mut agent_messages: Vec = Vec::new(); @@ -93,10 +97,49 @@ pub(super) fn dispatch_copy_assistant_message(app: &mut AppView, n: usize) { } let stats = crate::clipboard::clipboard_stats_suffix(text); - agent - .scrollback - .push_block(RenderBlock::system(format!("Copied to clipboard{stats}"))); - agent.copy_to_clipboard(text); + + if let Some(p) = file_path { + match crate::clipboard::write_text_to_copy_file(text, &p) { + Ok(path) => { + agent.scrollback.push_block(RenderBlock::system(format!( + "Copied to {}{stats}", + path.display() + ))); + } + Err(e) => { + agent + .scrollback + .push_block(RenderBlock::system(format!("Failed to write file: {e}"))); + } + } + return; + } + + let delivery = crate::clipboard::copy_text_or_file(text); + match &delivery { + crate::clipboard::CopyDelivery::Clipboard { file, .. } => { + let block_msg = match file { + Some(path) => format!( + "Copied to clipboard (also saved to {}){stats}", + crate::clipboard::display_copy_path(path) + ), + None => format!("Copied to clipboard{stats}"), + }; + agent.scrollback.push_block(RenderBlock::system(block_msg)); + } + crate::clipboard::CopyDelivery::File { path } => { + agent.scrollback.push_block(RenderBlock::system(format!( + "Clipboard unreachable — wrote {}{stats}", + crate::clipboard::display_copy_path(path) + ))); + } + crate::clipboard::CopyDelivery::Failed { .. } => { + agent + .scrollback + .push_block(RenderBlock::system(format!("Copy failed{stats}"))); + } + } + agent.show_toast_ticks(delivery.toast_message().as_ref(), delivery.toast_ticks()); }); } @@ -152,11 +195,28 @@ pub(super) fn dispatch_export_conversation( } else { // Clipboard path: stats block (like assistant copy) + route-aware toast // (like block content copy / selection). Good UX for a potentially large transcript. + // The scrollback line reflects where the copy actually landed — + // same pattern as /copy N — instead of claiming clipboard success + // when the delivery fell back to the backup file. let stats = crate::clipboard::clipboard_stats_suffix(&md); - agent.scrollback.push_block(RenderBlock::system(format!( - "Conversation copied to clipboard{stats}" - ))); - agent.copy_to_clipboard(&md); + let delivery = agent.copy_to_clipboard(&md); + let block_msg = match &delivery { + crate::clipboard::CopyDelivery::Clipboard { file, .. } => match file { + Some(path) => format!( + "Conversation copied to clipboard (also saved to {}){stats}", + crate::clipboard::display_copy_path(path) + ), + None => format!("Conversation copied to clipboard{stats}"), + }, + crate::clipboard::CopyDelivery::File { path } => format!( + "Clipboard unreachable — conversation written to {}{stats}", + crate::clipboard::display_copy_path(path) + ), + crate::clipboard::CopyDelivery::Failed { .. } => { + format!("Conversation copy failed{stats}") + } + }; + agent.scrollback.push_block(RenderBlock::system(block_msg)); } }); } diff --git a/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs b/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs index 8e62168..4adfeb0 100644 --- a/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs +++ b/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs @@ -1058,9 +1058,9 @@ impl AgentView { fn complete_mermaid_action(&mut self, action: MermaidClickAction, path: &Path) { let ok = match action { MermaidClickAction::Open => self.open_media_natively(path), - MermaidClickAction::CopyPath => !self + MermaidClickAction::CopyPath => self .copy_to_clipboard(&path.display().to_string()) - .is_failed(), + .success(), }; if !ok { crate::unified_log::error( @@ -2220,6 +2220,7 @@ mod tests { assert!( toast.starts_with("Copied") || toast.starts_with("Copy sent") + || toast.starts_with("Clipboard unreachable") || toast.starts_with("Copy failed"), "a disk hit runs the copy action immediately, got {toast:?}", ); diff --git a/crates/codegen/xai-grok-pager/src/app/mouse.rs b/crates/codegen/xai-grok-pager/src/app/mouse.rs index a6c0d25..2406e47 100644 --- a/crates/codegen/xai-grok-pager/src/app/mouse.rs +++ b/crates/codegen/xai-grok-pager/src/app/mouse.rs @@ -541,7 +541,25 @@ impl AgentView { return InputOutcome::Changed; } } - TaskEntryId::Scheduled(_) => {} + TaskEntryId::Scheduled(tid) => { + if let Some(sid) = self + .session + .scheduled_tasks + .get(tid) + .and_then(|info| info.last_subagent_id.clone()) + && let Some(child_sid) = self + .subagent_sessions + .iter() + .find(|(_, info)| { + info.subagent_id.as_ref() == sid.as_str() + }) + .map(|(k, _)| k.clone()) + && self.subagent_views.contains_key(&child_sid) + { + self.open_subagent_fullscreen(child_sid); + return InputOutcome::Changed; + } + } } } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/hook.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/hook.rs index 7e66238..6c954a8 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/hook.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/hook.rs @@ -16,9 +16,19 @@ use crate::theme::Theme; /// Status of a single hook execution within a batch. #[derive(Debug, Clone)] pub enum HookRunStatus { - Success { elapsed: Duration }, + Success { + elapsed: Duration, + }, Skipped, - Failed { error: String, elapsed: Duration }, + /// The hook ran and blocked (a stop-gate decision, not a failure). + Blocked { + detail: String, + elapsed: Duration, + }, + Failed { + error: String, + elapsed: Duration, + }, } /// A single hook run entry for display. @@ -74,7 +84,7 @@ fn count_hooks(entries: &[&[HookRunEntry]]) -> (usize, usize) { for runs in entries { for r in *runs { match r.status { - HookRunStatus::Success { .. } => success += 1, + HookRunStatus::Success { .. } | HookRunStatus::Blocked { .. } => success += 1, HookRunStatus::Failed { .. } => failed += 1, HookRunStatus::Skipped => {} } @@ -231,6 +241,27 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec { .into(), ); } + HookRunStatus::Blocked { detail, elapsed } => { + lines.push( + Line::from(vec![ + Span::styled(format!("{} ", INDENT), theme.muted()), + Span::styled("\u{21a9} ", theme.fg(theme.accent_running)), + Span::styled(run.name.clone(), theme.muted()), + Span::styled(format!(" ({}ms)", elapsed.as_millis()), theme.muted()), + ]) + .into(), + ); + let detail_text = crate::render::line_utils::truncate_str(detail, 120); + for detail_line in detail_text.lines().take(3) { + lines.push( + Line::from(vec![ + Span::styled(format!("{} ", INDENT), theme.muted()), + Span::styled(detail_line.to_string(), theme.fg(theme.accent_running)), + ]) + .into(), + ); + } + } HookRunStatus::Failed { error, elapsed } => { lines.push( Line::from(vec![ diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs index d53ac09..238f638 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs @@ -269,7 +269,9 @@ impl ReadToolCallBlock { let gutter_width = digit_count(base_line + raw_lines.len().saturating_sub(1)); let content_width = width.saturating_sub(gutter_width + 2).max(20); - let gutter_style = Style::default().fg(theme.gray_dim); + // Use Theme::dim/primary so terminal-native (minimal) maps grays to + // SGR dim / default fg instead of raw gray_dim slots. + let gutter_style = theme.dim(); let text_style = theme.primary(); let syntect = get_syntect(); diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs index 1b7b71a..3d113a4 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs @@ -1,9 +1,17 @@ //! `/copy` -- copy the last (or Nth) assistant message to the clipboard. +//! +//! Optional file path writes instead of (or when) the clipboard is unreachable: +//! - `/copy` — latest → clipboard (file fallback on failure) +//! - `/copy 2` — 2nd-latest → clipboard +//! - `/copy out.txt` — latest → file +//! - `/copy 2 out.txt` — 2nd-latest → file + +use std::path::PathBuf; use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; -/// Copy an assistant message to the clipboard. +/// Copy an assistant message to the clipboard (or an optional file). pub struct CopyCommand; impl SlashCommand for CopyCommand { @@ -12,7 +20,7 @@ impl SlashCommand for CopyCommand { } fn description(&self) -> &str { - "Copy last response to clipboard (/copy N for Nth-latest)" + "Copy last response to clipboard or file (/copy [N] [file])" } fn session_scoped(&self) -> bool { @@ -20,7 +28,7 @@ impl SlashCommand for CopyCommand { } fn usage(&self) -> &str { - "/copy [N]" + "/copy [N] [file]" } fn takes_args(&self) -> bool { @@ -28,29 +36,42 @@ impl SlashCommand for CopyCommand { } fn arg_placeholder(&self) -> Option<&str> { - Some("[N]") + Some("[N] [file]") } fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult { - let trimmed = args.trim(); - let n = if trimmed.is_empty() { - 1 - } else { - match trimmed.parse::() { - Ok(0) => { - return CommandResult::Error( - "Usage: /copy [N] where N is 1 (latest), 2, 3, ...".to_string(), - ); - } - Ok(v) => v, - Err(_) => { - return CommandResult::Error(format!( - "/copy {trimmed} (invalid number)\nUsage: /copy [N] where N is 1 (latest), 2, 3, ..." - )); - } + match parse_copy_args(args) { + Ok((n, file_path)) => { + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) } - }; - CommandResult::Action(Action::CopyAssistantMessage { n }) + Err(msg) => CommandResult::Error(msg), + } + } +} + +/// Parse `/copy` args into `(n, optional_file_path)`. +/// +/// - empty → `(1, None)` +/// - `2` → `(2, None)` +/// - `out.txt` → `(1, Some(out.txt))` +/// - `2 out.txt` → `(2, Some(out.txt))` (rest of line is the path, spaces ok) +fn parse_copy_args(args: &str) -> Result<(usize, Option), String> { + let trimmed = args.trim(); + if trimmed.is_empty() { + return Ok((1, None)); + } + + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or(""); + let rest = parts.next().map(str::trim).filter(|s| !s.is_empty()); + + match first.parse::() { + Ok(0) => Err("Usage: /copy [N] [file] where N is 1 (latest), 2, 3, ...".to_string()), + Ok(n) => Ok((n, rest.map(PathBuf::from))), + Err(_) => { + // Non-numeric first token: treat the whole args string as a path. + Ok((1, Some(PathBuf::from(trimmed)))) + } } } @@ -88,7 +109,10 @@ mod tests { let mut ctx = make_ctx(&models); let cmd = CopyCommand; match cmd.run(&mut ctx, "") { - CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1), + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => { + assert_eq!(n, 1); + assert!(file_path.is_none()); + } other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } @@ -99,7 +123,10 @@ mod tests { let mut ctx = make_ctx(&models); let cmd = CopyCommand; match cmd.run(&mut ctx, "1") { - CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1), + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => { + assert_eq!(n, 1); + assert!(file_path.is_none()); + } other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } @@ -110,7 +137,10 @@ mod tests { let mut ctx = make_ctx(&models); let cmd = CopyCommand; match cmd.run(&mut ctx, "3") { - CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 3), + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => { + assert_eq!(n, 3); + assert!(file_path.is_none()); + } other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } @@ -124,23 +154,37 @@ mod tests { } #[test] - fn non_numeric_returns_error() { + fn path_only_writes_latest_to_file() { let models = ModelState::default(); let mut ctx = make_ctx(&models); let cmd = CopyCommand; - match cmd.run(&mut ctx, "abc") { - CommandResult::Error(msg) => assert!(msg.contains("invalid number")), - other => panic!("expected Error, got {other:?}"), + match cmd.run(&mut ctx, "out.txt") { + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => { + assert_eq!(n, 1); + assert_eq!(file_path.as_deref(), Some(std::path::Path::new("out.txt"))); + } + other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } + #[test] + fn n_and_path_with_spaces() { + assert_eq!( + parse_copy_args("2 ~/exports/my note.txt").unwrap(), + (2, Some(PathBuf::from("~/exports/my note.txt"))) + ); + } + #[test] fn whitespace_only_copies_latest() { let models = ModelState::default(); let mut ctx = make_ctx(&models); let cmd = CopyCommand; match cmd.run(&mut ctx, " ") { - CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1), + CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => { + assert_eq!(n, 1); + assert!(file_path.is_none()); + } other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs index c97c020..2dc0750 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs @@ -1885,6 +1885,7 @@ mod tests { created_at: std::time::Instant::now(), next_fire_at: None, tag: "loop".into(), + last_subagent_id: None, } } /// A turn-idle agent with a RUNNING background task is `Working`, not diff --git a/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs b/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs index 618735d..35ad2f9 100644 --- a/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs +++ b/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs @@ -1146,7 +1146,13 @@ fn highlight_to_ratatui_line( if piece.is_empty() { continue; } - let fg = syntect_to_ratatui_color(style.foreground); + // Shared path: polarity-safe under the terminal-native lock, else + // normal theme quantize (see xai_grok_pager_render::syntax). + let fg = crate::syntax::syntect_rgb_to_fg( + style.foreground.r, + style.foreground.g, + style.foreground.b, + ); spans.push(Span::styled(piece, Style::default().fg(fg))); } @@ -1156,13 +1162,6 @@ fn highlight_to_ratatui_line( Line::from(spans) } -/// Convert a syntect RGBA color to a ratatui Color. -/// -/// Quantizes the RGB value to the terminal's supported color level. -fn syntect_to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color { - crate::theme::quantize(ratatui::style::Color::Rgb(c.r, c.g, c.b)) -} - /// Count digits in a number (for line number padding). fn digit_count(n: usize) -> usize { if n == 0 { diff --git a/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs b/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs index f726134..875addc 100644 --- a/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs @@ -238,6 +238,7 @@ pub enum TaskEntry { label: String, styled: Line<'static>, started_at: Instant, + linked_subagent: Option, }, /// Collapsible group header row (e.g. `▾ Subagents 2`). Not a task — /// selecting it and pressing Enter (or clicking it) toggles the group's @@ -448,7 +449,9 @@ impl TaskEntry { info: &ScheduledTaskInfo, current_cron: Option<&str>, is_queued: bool, + linked: Option<(String, bool)>, ) -> Self { + let linked_running = linked.as_ref().is_some_and(|(_, running)| *running); let theme = Theme::current(); let prompt_preview = if info.prompt.chars().count() > 60 { info.prompt.chars().take(57).collect::() + "..." @@ -469,7 +472,7 @@ impl TaskEntry { } }; let is_provisional = info.task_id.starts_with("provisional-"); - let suffix = if current_cron == Some(&info.task_id) { + let suffix = if current_cron == Some(&info.task_id) || linked_running { " (running)".to_string() } else if is_queued { " (queued)".to_string() @@ -501,7 +504,7 @@ impl TaskEntry { } }; let label = format!( - "{} {} {}{}", + "{} {} \u{b7} {}{}", tag_display, info.human_schedule, &prompt_preview, &suffix ); @@ -510,11 +513,11 @@ impl TaskEntry { // neutral secondary text color so the row reads calmly with a single // point of color. No surrounding `[ ]` brackets: the color alone // sets the tag apart from the schedule that follows it. - let schedule_style = format!("{} ", info.human_schedule); + let schedule_style = format!("{} \u{b7} ", info.human_schedule); let neutral = Style::default().fg(theme.text_secondary); let styled = Line::from(vec![ Span::styled( - format!("{} ", tag_display), + format!("{} ", tag_display), Style::default().fg(theme.accent_system), ), Span::styled(schedule_style, neutral), @@ -537,6 +540,7 @@ impl TaskEntry { label, styled, started_at: info.created_at, + linked_subagent: linked.map(|(sid, _)| sid), } } @@ -660,7 +664,7 @@ impl ListItem for TaskEntry { enum OverlayEntryData { BgTask(String), Agent(String, String), - Scheduled(String), + Scheduled(String, Option), } const MAX_TASKS_HEIGHT: u16 = 8; @@ -834,10 +838,17 @@ impl TasksPane { // Add scheduled task items (always "running") for info in scheduled.values() { + let linked = info.last_subagent_id.as_deref().and_then(|sid| { + subagents + .values() + .find(|s| s.subagent_id.as_ref() == sid) + .map(|s| (sid.to_string(), s.is_running())) + }); self.items.push(TaskEntry::from_scheduled( info, current_cron_task_id, queued_cron_ids.contains(info.task_id.as_str()), + linked, )); } @@ -1307,9 +1318,11 @@ impl TasksPane { child_session_id, .. } => OverlayEntryData::Agent(subagent_id.clone(), child_session_id.clone()), - TaskEntry::Scheduled { task_id, .. } => { - OverlayEntryData::Scheduled(task_id.clone()) - } + TaskEntry::Scheduled { + task_id, + linked_subagent, + .. + } => OverlayEntryData::Scheduled(task_id.clone(), linked_subagent.clone()), // Group headers have no kill/view buttons; they still // occupy a row (vis_row is enumerated before this filter), // so the y offsets for following items stay correct. @@ -1333,8 +1346,15 @@ impl TasksPane { }; self.render_agent_overlay(area, buf, y, subagent_id, info, &theme); } - OverlayEntryData::Scheduled(ref task_id) => { - self.render_scheduled_overlay(area, buf, y, task_id, &theme); + OverlayEntryData::Scheduled(ref task_id, ref linked_subagent) => { + self.render_scheduled_overlay( + area, + buf, + y, + task_id, + linked_subagent.as_deref(), + &theme, + ); } } } @@ -1643,6 +1663,7 @@ impl TasksPane { buf: &mut Buffer, y: u16, task_id: &str, + linked_subagent: Option<&str>, theme: &Theme, ) { let frames = crate::glyphs::dot_spinner_frames(); @@ -1654,8 +1675,8 @@ impl TasksPane { 2, ); - // Clear overlay area (kill button + separator = 4 cols). - clear_overlay_area(buf, area, y, 4); + let overlay_cols = if linked_subagent.is_some() { 7 } else { 4 }; + clear_overlay_area(buf, area, y, overlay_cols); let mut rx = area.x + area.width; @@ -1681,6 +1702,29 @@ impl TasksPane { Rect::new(rx, y, 3, 1), )); + if linked_subagent.is_some() { + rx = rx.saturating_sub(3); + let is_view_hovered = matches!( + &self.hovered_view, + Some(TaskEntryId::Scheduled(tid)) if tid == task_id + ); + let view_style = if is_view_hovered { + Style::default().fg(theme.text_primary) + } else { + Style::default().fg(theme.gray) + }; + buf.set_span( + rx, + y, + &Span::styled(crate::glyphs::enlarge_button(), view_style), + 3, + ); + self.view_button_rects.push(( + TaskEntryId::Scheduled(task_id.to_string()), + Rect::new(rx, y, 3, 1), + )); + } + if rx > area.x { buf.set_span(rx - 1, y, &Span::raw(" "), 1); } @@ -2899,6 +2943,7 @@ mod tests { created_at: std::time::Instant::now(), next_fire_at: next.map(|s| s.to_string()), tag: "loop".into(), + last_subagent_id: None, } } diff --git a/crates/codegen/xai-grok-pager/tests/grok_home_paths.rs b/crates/codegen/xai-grok-pager/tests/grok_home_paths.rs index f82be80..6f9bbfe 100644 --- a/crates/codegen/xai-grok-pager/tests/grok_home_paths.rs +++ b/crates/codegen/xai-grok-pager/tests/grok_home_paths.rs @@ -30,6 +30,13 @@ fn grok_home_override_path_helpers() { "$GROK_HOME/memory/MEMORY.md" ); + // Copy-toast paths follow the same abbreviation convention, so a custom + // $GROK_HOME outside $HOME still displays short. + assert_eq!( + xai_grok_pager::clipboard::display_copy_path(&grok_home.join("last-copy.txt")), + "$GROK_HOME/last-copy.txt" + ); + assert!(xai_grok_pager::util::is_under_user_grok_home(&memory_path)); assert!(!xai_grok_pager::util::is_under_user_grok_home( PathBuf::from("/tmp/other").as_path() diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs index 7b200ee..a1af156 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs @@ -401,10 +401,13 @@ async fn cancel_before_task_completion_defers_auto_wake_until_user_prompt() { harness .wait_for_full_text("Task completed in", Duration::from_secs(15)) .expect("background completion chip"); + // hold must be strictly less than timeout: wait_until_stable stamps + // true_since after the deadline is fixed, so timeout == hold flakes under + // load even when the condition is always true (remote --runs_per_test). harness .wait_until_stable( "no auto-wake response after background completion", - Duration::from_secs(2), + Duration::from_secs(5), Duration::from_secs(2), |h| !h.contains_full_text(UNWANTED_AUTO_WAKE_SENTINEL), ) @@ -443,7 +446,7 @@ async fn cancel_before_task_completion_defers_auto_wake_until_user_prompt() { harness .wait_until_stable( "no second completion request after the user turn", - Duration::from_secs(2), + Duration::from_secs(5), Duration::from_secs(2), |_| { content diff --git a/crates/codegen/xai-grok-sampling-types/src/conversation.rs b/crates/codegen/xai-grok-sampling-types/src/conversation.rs index 98c8a73..ad485d4 100644 --- a/crates/codegen/xai-grok-sampling-types/src/conversation.rs +++ b/crates/codegen/xai-grok-sampling-types/src/conversation.rs @@ -114,6 +114,9 @@ pub enum SyntheticReason { /// Scheduled task (`/loop`) prompt fired by the scheduler. Wakes the /// agent. SchedulerFired, + /// Feedback from a `Stop`/`SubagentStop` hook that blocked the agent from + /// stopping. Injected in-turn so the model keeps working within the same turn. + StopHookFeedback, /// Catch-all for unknown/future variants. Preserves forward compatibility /// so older clients can deserialize sessions written by newer versions. #[serde(other)] @@ -148,6 +151,7 @@ impl SyntheticReason { | Self::AutoRecovery | Self::Interjection | Self::GoalSummary + | Self::StopHookFeedback | Self::Unknown => false, } } @@ -1051,6 +1055,18 @@ impl ConversationItem { }) } + /// See [`SyntheticReason::StopHookFeedback`]. + pub fn stop_hook_feedback(content: impl Into) -> Self { + Self::User(UserItem { + content: vec![ContentPart::Text { + text: Arc::::from(content.into()), + }], + synthetic_reason: Some(SyntheticReason::StopHookFeedback), + prior_turn_interrupt: None, + prompt_index: None, + }) + } + /// Create an assistant message pub fn assistant(content: impl Into) -> Self { Self::Assistant(AssistantItem { diff --git a/crates/codegen/xai-grok-shell/CHANGELOG.md b/crates/codegen/xai-grok-shell/CHANGELOG.md index 0dc958d..1224750 100644 --- a/crates/codegen/xai-grok-shell/CHANGELOG.md +++ b/crates/codegen/xai-grok-shell/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +# 0.2.106 — 2026-07-18 + +## Features + +- **Added GROK_CLIPBOARD_NO_OSC52** env var to stop clipboard sequences from appearing as garbage in unsupported terminals. +- **Scheduled tasks** can now be updated in place; one-time tasks are retired in favor of background commands. + +## Bug Fixes + +- **Copies** now always write a backup file so text remains recoverable when the terminal clipboard fails. +- **Syntax highlighting** in --minimal mode is now visible on light terminals. + + # 0.2.105 — 2026-07-18 ## Features diff --git a/crates/codegen/xai-grok-shell/Cargo.toml b/crates/codegen/xai-grok-shell/Cargo.toml index 14d9785..e503829 100644 --- a/crates/codegen/xai-grok-shell/Cargo.toml +++ b/crates/codegen/xai-grok-shell/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-shell" -version = "0.2.105" +version = "0.2.106" edition.workspace = true [features] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.106.json b/crates/codegen/xai-grok-shell/changelogs/0.2.106.json new file mode 100644 index 0000000..cca13e4 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.106.json @@ -0,0 +1,22 @@ +[ + { + "category": "features", + "description": "**Added GROK_CLIPBOARD_NO_OSC52** env var to stop clipboard sequences from appearing as garbage in unsupported terminals.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Copies** now always write a backup file so text remains recoverable when the terminal clipboard fails.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Scheduled tasks** can now be updated in place; one-time tasks are retired in favor of background commands.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Syntax highlighting** in --minimal mode is now visible on light terminals.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.106.md b/crates/codegen/xai-grok-shell/changelogs/0.2.106.md new file mode 100644 index 0000000..80b72ec --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.106.md @@ -0,0 +1,12 @@ +# 0.2.106 — 2026-07-18 + +## Features + +- **Added GROK_CLIPBOARD_NO_OSC52** env var to stop clipboard sequences from appearing as garbage in unsupported terminals. +- **Scheduled tasks** can now be updated in place; one-time tasks are retired in favor of background commands. + +## Bug Fixes + +- **Copies** now always write a backup file so text remains recoverable when the terminal clipboard fails. +- **Syntax highlighting** in --minimal mode is now visible on light terminals. + diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs index c76f1b3..c1660ed 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs @@ -400,8 +400,10 @@ impl acp::Agent for MvpAgent { .meta( serde_json::json!( { "x.ai/fs_notify" : true, "x.ai/hooks" : { "blockingEvents" - : [xai_grok_hooks::event::HookEventName::PreToolUse], - "decisions" : ["deny"], }, } + : crate ::extensions::hooks::ADVERTISED_BLOCKING_EVENTS, + "decisions" : crate + ::extensions::hooks::ADVERTISED_DECISIONS, "stopSignals" : + crate ::extensions::hooks::ADVERTISED_STOP_SIGNALS, }, } ) .as_object() .cloned(), @@ -1975,12 +1977,7 @@ impl acp::Agent for MvpAgent { #[tracing::instrument( name = "agent.prompt", skip_all, - fields( - session_id = %arguments.session_id.0, - turn_number = tracing::field::Empty, - uploads_enabled = tracing::field::Empty, - upload_reason = tracing::field::Empty, - ) + fields(session_id = %arguments.session_id.0, turn_number = tracing::field::Empty) )] #[allow(unused_mut)] async fn prompt( @@ -3187,6 +3184,12 @@ impl acp::Agent for MvpAgent { "x.ai/session/updates" => { crate::extensions::session_updates::handle(&args, &self.gateway).await } + "x.ai/session/state" => { + crate::extensions::session_state::handle_state(&args).await + } + "x.ai/session/import" => { + crate::extensions::session_state::handle_import(&args).await + } "x.ai/session/load_history" => { crate::extensions::chat_conversation_history::handle(self, &args).await } diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs index b10c454..7f506cb 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs @@ -2170,8 +2170,7 @@ impl MvpAgent { ) } /// Like `trace_upload_config`, but also returns the reason why uploads - /// are enabled/disabled. Used by `get_trace_context` to record - /// `upload_reason` on the `agent.prompt` span. + /// are enabled or disabled for structured session events. async fn trace_upload_config_with_reason( &self, ) -> ( @@ -2602,7 +2601,6 @@ impl MvpAgent { let (upload_method, upload_reason) = self .trace_upload_config_with_reason() .await; - tracing::Span::current().record("upload_reason", upload_reason.as_str()); { let mut decision = self.cfg.borrow().trace_upload_decision_debug(); if let Some(obj) = decision.as_object_mut() { @@ -2627,12 +2625,8 @@ impl MvpAgent { ); } let upload_method = match upload_method { - Some(method) => { - tracing::Span::current().record("uploads_enabled", true); - method - } + Some(method) => method, None => { - tracing::Span::current().record("uploads_enabled", false); xai_grok_telemetry::session_ctx::log_session_event(crate::agent::session_metrics::TraceUploadSkipped { session_id: session_info.id.0.to_string(), turn_number, @@ -2648,7 +2642,6 @@ impl MvpAgent { match cfg.endpoints.resolve_trace_bucket_url() { Some(resolved) => Some(resolved.value), None => { - tracing::Span::current().record("uploads_enabled", false); xai_grok_telemetry::session_ctx::log_session_event(crate::agent::session_metrics::TraceUploadSkipped { session_id: session_info.id.0.to_string(), turn_number, @@ -2676,12 +2669,6 @@ impl MvpAgent { let session_handle = match self.sessions.borrow().get(&session_info.id) { Some(h) => h.clone(), None => { - tracing::Span::current().record("uploads_enabled", false); - tracing::Span::current() - .record( - "upload_reason", - crate::upload::turn::TraceUploadReason::SessionNotFound.as_str(), - ); return None; } }; diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs index e17a72b..e1b3b78 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs @@ -25,12 +25,42 @@ impl MvpAgent { while let Some(event) = rx.recv().await { match event { SubagentEvent::Spawn(boxed) => { - let request = *boxed; + let mut request = *boxed; let agent_ref = agent_ref.clone(); tokio::task::spawn_local(async move { let this = agent_ref.get(); + let parent_is_session = this.sessions.borrow().contains_key( + &acp::SessionId::new(request.parent_session_id.clone()), + ); + if !parent_is_session + && let Some(root) = this + .subagent_coordinator + .borrow() + .parent_of_child_session(&request.parent_session_id) + { + tracing::info!( + child_session_id = % request.parent_session_id, + root_session_id = % root, subagent_id = % request.id, + "Re-parenting child-session spawn to root session" + ); + request.parent_session_id = root; + request.surface_completion = false; + } let parent_sid = request.parent_session_id.clone(); - let mut ctx = this.build_subagent_spawn_context(&parent_sid); + let Some(mut ctx) = + this.try_build_subagent_spawn_context(&parent_sid) + else { + tracing::warn!( + parent_session_id = % parent_sid, subagent_id = % request + .id, + "Spawn for unknown/evicted parent session, failing request" + ); + crate::agent::subagent::send_failure( + request, + "Parent session not found (evicted or torn down); cannot spawn subagent.", + ); + return; + }; let parent_handle = { let parent_sid_acp = acp::SessionId::new(parent_sid.clone()); this.sessions.borrow().get(&parent_sid_acp).cloned() @@ -283,16 +313,12 @@ impl MvpAgent { cli_agent_names, } } - /// Build a `SubagentSpawnContext` from the current agent state and the - /// parent session's shared resources. - /// - /// This is the ONLY subagent-related method on MvpAgent besides the - /// coordinator startup. - /// Build a spawn context for a real subagent spawn. The parent session is - /// guaranteed present here because the parent just issued the spawn request, - /// so a missing parent is a real invariant violation and panics. Read-only - /// callers that can race a parent teardown (e.g. `DescribeType`) must use - /// [`Self::try_build_subagent_spawn_context`] instead. + /// Test-only infallible wrapper around + /// [`Self::try_build_subagent_spawn_context`]. Production spawn paths use + /// the fallible variant and fail the request when the parent session is + /// absent (evicted, or a child-session spawn whose re-parent lookup + /// missed). + #[cfg(test)] pub(super) fn build_subagent_spawn_context( &self, parent_session_id: &str, @@ -300,10 +326,13 @@ impl MvpAgent { self.try_build_subagent_spawn_context(parent_session_id) .expect("parent session must exist when spawning subagents") } - /// Fallible variant of [`Self::build_subagent_spawn_context`]: returns - /// `None` when the parent `SessionHandle` is absent (evicted / torn down) - /// instead of panicking, so read-only paths that can race a teardown can - /// fail open. + /// Build a `SubagentSpawnContext` from the current agent state and the + /// parent session's shared resources. Returns `None` when the parent + /// `SessionHandle` is absent (evicted / torn down) so callers can fail + /// the request instead of panicking. + /// + /// This is the ONLY subagent-related method on MvpAgent besides the + /// coordinator startup. pub(super) fn try_build_subagent_spawn_context( &self, parent_session_id: &str, diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs index 4de09a7..a608af9 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs @@ -1,15 +1,25 @@ //! Heap-leak test for the session lifecycle: create and remove many sessions, //! then fail if heap memory grows per session. Run: +//! cargo test -p xai-grok-shell --features dhat-heap \ //! leader_session_lifecycle_heap_steady_state -- --ignored --nocapture use super::*; use xai_grok_workspace::permission::PermissionEvent; + +// Chosen between a healthy build (about zero retained allocations per +// session) and the smallest deliberately introduced leak (one per session); +// re-tune if healthy runs drift toward the limits. const MAX_BLOCKS_PER_SESSION: f64 = 0.5; const MAX_BYTES_PER_SESSION: f64 = 1024.0; + /// Creates the per-session state that `remove_session` must clean up, then /// removes the session. A full `SessionHandle` would allocate so much /// unrelated memory that a small leak would be lost in the noise. fn populate_and_evict(agent: &MvpAgent, i: usize) { let sid = acp::SessionId::new(format!("soak-{i}")); + + // The same workspace binding `spawn_session_actor` creates; if + // `remove_session` does not release it, the session map holds every + // toolset for the life of the process. { let ops = agent.workspace_ops.borrow(); let ops = ops.as_ref().expect("test installs workspace ops"); @@ -25,6 +35,7 @@ fn populate_and_evict(agent: &MvpAgent, i: usize) { ) .expect("bind_local_session must succeed"); } + let (_ptx, prx) = tokio::sync::mpsc::unbounded_channel::(); agent .permission_event_receivers @@ -38,8 +49,10 @@ fn populate_and_evict(agent: &MvpAgent, i: usize) { sid.0.to_string(), acp::ModelId::new(std::sync::Arc::from("gone-model")), ); + agent.remove_session(&sid); } + /// Waits for background tasks to finish before reading heap stats. async fn quiesce() { const YIELD_ROUNDS: usize = 50; @@ -52,43 +65,58 @@ async fn quiesce() { tokio::task::yield_now().await; } } + /// Creating and removing N sessions must not grow the heap. /// /// Only one `dhat::Profiler` can exist at a time, and the test harness runs /// tests in parallel, so keep this the only test that creates one. #[test] +#[ignore = "heap soak; nightly only, needs --features dhat-heap"] fn leader_session_lifecycle_heap_steady_state() { run_local_for_bridge_test(|| async { let agent = build_minimal_agent_for_tests(); *agent.workspace_ops.borrow_mut() = Some(xai_grok_workspace::WorkspaceOps::for_test()); let _profiler = dhat::Profiler::builder().testing().build(); + const WARMUP: usize = 16; const MEASURE: usize = 256; + + // The first runs fill caches and one-time allocations; do them before + // the measured window so they do not count as growth. for i in 0..WARMUP { populate_and_evict(&agent, i); } quiesce().await; let before = dhat::HeapStats::get(); + for i in WARMUP..(WARMUP + MEASURE) { populate_and_evict(&agent, i); } quiesce().await; let after = dhat::HeapStats::get(); + let d_blocks = after.curr_blocks as i64 - before.curr_blocks as i64; let d_bytes = after.curr_bytes as i64 - before.curr_bytes as i64; let blocks_per = d_blocks as f64 / MEASURE as f64; let bytes_per = d_bytes as f64 / MEASURE as f64; + // Printed before the asserts so failing runs still show the numbers. eprintln!( "DHAT_SOAK_SUMMARY {}", - serde_json::json!({ "warmup_sessions" : WARMUP, - "measured_sessions" : MEASURE, "before_blocks" : before.curr_blocks, - "before_bytes" : before.curr_bytes, "after_blocks" : after.curr_blocks, - "after_bytes" : after.curr_bytes, "blocks_per_session" : blocks_per, - "bytes_per_session" : bytes_per, "max_blocks_per_session" : - MAX_BLOCKS_PER_SESSION, "max_bytes_per_session" : MAX_BYTES_PER_SESSION, - "pass" : blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per < - MAX_BYTES_PER_SESSION }) + serde_json::json!({ + "warmup_sessions": WARMUP, + "measured_sessions": MEASURE, + "before_blocks": before.curr_blocks, + "before_bytes": before.curr_bytes, + "after_blocks": after.curr_blocks, + "after_bytes": after.curr_bytes, + "blocks_per_session": blocks_per, + "bytes_per_session": bytes_per, + "max_blocks_per_session": MAX_BLOCKS_PER_SESSION, + "max_bytes_per_session": MAX_BYTES_PER_SESSION, + "pass": blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per < MAX_BYTES_PER_SESSION + }) ); + assert!( blocks_per < MAX_BLOCKS_PER_SESSION, "block-count leak: {blocks_per:.3} blocks/session retained ({d_blocks} over {MEASURE} cycles) exceeds the {MAX_BLOCKS_PER_SESSION} gate" diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs index 4d578cb..1215aa0 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs @@ -323,6 +323,7 @@ impl SubagentCoordinator { effective_model_id: String::new(), block_waited: false, explicitly_killed: false, + completion_output_cap: None, persisted_output_dir: None, }, ); @@ -386,6 +387,9 @@ impl SubagentCoordinator { let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited); let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed); let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion); + let completion_output_cap = tracker + .as_ref() + .and_then(|t| t.completion_output_cap); let mut completed = CompletedSubagent { subagent_id: id.to_string(), parent_session_id, @@ -404,6 +408,7 @@ impl SubagentCoordinator { effective_model_id, block_waited, explicitly_killed, + completion_output_cap, persisted_output_dir, }; let success = completed.result.success && !completed.result.cancelled; @@ -440,7 +445,10 @@ impl SubagentCoordinator { duration_ms: completed.result.duration_ms, tool_calls: completed.result.tool_calls, turns: completed.result.turns, - output: completed.result.output.clone(), + output: super::cap_completion_output( + &completed.result.output, + completed.completion_output_cap, + ), }); } if completed.persisted_output_dir.is_some() { diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs index d12fde5..b7b1031 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs @@ -106,6 +106,19 @@ impl SubagentCoordinator { } None } + /// Parent session of the running subagent whose child session is + /// `child_session_id`. Used to re-parent spawn requests that originate + /// inside a child session (e.g. a loop iteration spawning its own + /// subagent) to the root session that owns it. + pub(crate) fn parent_of_child_session( + &self, + child_session_id: &str, + ) -> Option { + self.active + .values() + .find(|t| t.child_session_id.0.as_ref() == child_session_id) + .map(|t| t.parent_session_id.clone()) + } /// Return `(parent_session_id, child_session_id)` for a given subagent. /// /// Checks active first, then completed. Returns `None` if not found. diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs index b47aa73..645dbef 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs @@ -25,6 +25,24 @@ use xai_grok_tools::implementations::grok_build::task::types::*; use xai_grok_workspace::file_system::AsyncFileSystem; use xai_hunk_tracker::HunkTrackerHandle; use super::*; +/// Remove the task tool (and orphaned background-task actions) from a child +/// toolset at or beyond `MAX_SUBAGENT_DEPTH`. Returns whether the task tool +/// was removed. +pub(super) fn strip_task_tools_at_max_depth( + tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig, + child_depth: u32, +) -> bool { + use xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH; + use xai_grok_tools::types::tool::ToolKind; + if child_depth < MAX_SUBAGENT_DEPTH { + return false; + } + let before = tool_config.tools.len(); + tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task)); + let stripped = tool_config.tools.len() < before; + prune_orphaned_background_task_tools(tool_config); + stripped +} pub(super) fn task_model_override_error( requested: Option<&str>, provenance: ModelOverrideProvenance, @@ -401,21 +419,15 @@ pub(crate) async fn handle_subagent_request( "Applied capability mode filter to agent tool config" ); } - { - use xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH; - use xai_grok_tools::types::tool::ToolKind; - let child_depth = ctx.parent_depth + 1; - if child_depth >= MAX_SUBAGENT_DEPTH { - let before = definition.tool_config.tools.len(); - definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task)); - if definition.tool_config.tools.len() < before { - tracing::info!( - subagent_id = % request.id, child_depth, max_depth = - MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth" - ); - } - prune_orphaned_background_task_tools(&mut definition.tool_config); - } + let child_depth = request + .runtime_overrides + .spawn_depth + .unwrap_or(ctx.parent_depth + 1); + if strip_task_tools_at_max_depth(&mut definition.tool_config, child_depth) { + tracing::info!( + subagent_id = % request.id, child_depth, + "Stripped task tool from child at max depth" + ); } if request.fork_context { effective_runtime.model = Some(ctx.model_id.0.to_string()); @@ -624,7 +636,7 @@ pub(crate) async fn handle_subagent_request( .capability_mode .as_ref() .map(|m| format!("{m:?}")), - depth: ctx.parent_depth + 1, + depth: child_depth, }; emit_subagent_notification( gateway, @@ -748,7 +760,7 @@ pub(crate) async fn handle_subagent_request( .with_hunk_tracking_enabled(ctx.hunk_tracking_enabled); tool_ctx.subagent_event_tx = Some(ctx.subagent_event_tx.clone()); tool_ctx.monitor_event_buffer = Some(MonitorEventBuffer::default()); - tool_ctx.subagent_depth = ctx.parent_depth + 1; + tool_ctx.subagent_depth = child_depth; tool_ctx.lsp = ctx.lsp.clone(); let parent_traceparent = xai_file_utils::trace_context::current_traceparent(); let tracker_child_cwd = child_session_info.cwd.clone(); @@ -1247,6 +1259,7 @@ pub(crate) async fn handle_subagent_request( effective_model_id: tracker_model_id, run_in_background, surface_completion: request.surface_completion, + completion_output_cap: request.runtime_overrides.completion_output_cap, color: tracker_color, block_waited: false, explicitly_killed: false, diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs index 5585ad0..94225a7 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs @@ -84,6 +84,7 @@ pub(crate) struct SubagentTracker { pub run_in_background: bool, /// Mirrors `SubagentRequest::surface_completion`. pub surface_completion: bool, + pub completion_output_cap: Option, /// Set when a `block=true` waiter consumed this subagent's result. pub block_waited: bool, /// Set when the model explicitly killed this subagent via the kill tool. @@ -133,7 +134,6 @@ impl AutoCompactThresholdTiers { } } /// Everything the coordinator needs from MvpAgent to spawn a child session. -/// /// Avoids passing `&MvpAgent` (which would require the coordinator to know /// about the full agent struct). Built by `MvpAgent::build_subagent_spawn_context()`. pub(crate) struct SubagentSpawnContext { @@ -501,6 +501,7 @@ pub(crate) struct CompletedSubagent { pub block_waited: bool, /// Set when the model explicitly killed this subagent via the kill tool. pub explicitly_killed: bool, + pub completion_output_cap: Option, /// Directory whose `output.json` holds the output text; when set, the /// stored `result.output` is cleared and `lookup` reads from disk. /// `None` (failures, empty outputs, failed writes) serves from memory. @@ -508,6 +509,26 @@ pub(crate) struct CompletedSubagent { /// `meta.json`, and trace upload carries the text to GCS. pub persisted_output_dir: Option, } +pub(crate) fn cap_completion_output( + output: &std::sync::Arc, + cap: Option, +) -> std::sync::Arc { + match cap { + Some(cap) if output.len() > cap => { + let mut end = cap; + while end > 0 && !output.is_char_boundary(end) { + end -= 1; + } + std::sync::Arc::from(format!( + "{}\n[output truncated: {} of {} bytes shown]", + &output[..end], + end, + output.len() + )) + } + _ => output.clone(), + } +} /// Lightweight entry for subagents that have been requested but are still /// initializing (creating worktree, resolving config, spawning session). /// Promoted to a full `SubagentTracker` once the child session is ready. @@ -2024,7 +2045,10 @@ fn inject_subagent_completed_prompt( duration_ms: result.duration_ms, tool_calls: result.tool_calls, turns: result.turns, - output: result.output.clone(), + output: cap_completion_output( + &result.output, + request.runtime_overrides.completion_output_cap, + ), }; let message = xai_grok_tools::reminders::task_completion::format_subagent_completion( &summary, @@ -2079,7 +2103,7 @@ fn inject_subagent_completed_prompt( } /// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot; /// `PendingGuard::drop` handles the queue side effects. -fn send_failure(request: SubagentRequest, error: &str) { +pub(crate) fn send_failure(request: SubagentRequest, error: &str) { let _ = request.result_tx.send(SubagentResult { success: false, error: Some(error.to_string()), diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs index 2027d63..f21c5a3 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs @@ -924,6 +924,7 @@ fn completed_with_output( effective_model_id: String::new(), block_waited: false, explicitly_killed: false, + completion_output_cap: None, persisted_output_dir, } } @@ -1309,6 +1310,7 @@ fn dummy_tracker( effective_model_id: String::new(), run_in_background: false, surface_completion: true, + completion_output_cap: None, color: None, block_waited: false, explicitly_killed: false, @@ -1341,6 +1343,26 @@ async fn active_summaries_returns_all_regardless_of_parent() { let all = coordinator.active_summaries(); assert_eq!(all.len(), 2); } +/// Spawns issued from inside a child session (loop iterations) re-parent +/// to the root session via the running tracker's child→parent mapping. +#[tokio::test] +async fn parent_of_child_session_maps_to_root() { + let mut coordinator = SubagentCoordinator::new(); + coordinator + .insert( + dummy_tracker( + "iter-child-sess", + "root-session", + "general-purpose", + "loop iteration", + ), + ); + assert_eq!( + coordinator.parent_of_child_session("iter-child-sess").as_deref(), + Some("root-session") + ); + assert_eq!(coordinator.parent_of_child_session("unknown-sess"), None); +} #[tokio::test] async fn resolve_running_list_returns_empty_for_empty_seeds() { let resolved = resolve_running_list(vec![]).await; diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs index 0066a4e..993110a 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs @@ -346,6 +346,7 @@ fn resumable_source_returns_info_for_completed_subagent() { effective_model_id: "grok-3".into(), block_waited: false, explicitly_killed: false, + completion_output_cap: None, persisted_output_dir: None, }, ); @@ -1358,6 +1359,7 @@ fn resumable_source_rejects_cross_session_lookup() { effective_model_id: String::new(), block_waited: false, explicitly_killed: false, + completion_output_cap: None, persisted_output_dir: None, }, ); @@ -2176,6 +2178,7 @@ fn completed_subagent_propagates_resumed_from() { effective_model_id: "grok-3".into(), block_waited: false, explicitly_killed: false, + completion_output_cap: None, persisted_output_dir: None, }, ); @@ -3308,3 +3311,23 @@ async fn progress_publisher_delivers_ticks_to_parent_cmd_channel() { }) .await; } +/// A harness-pinned `spawn_depth` of 0 (scheduler loop iterations) keeps +/// the task tool in the child toolset; a natural depth-1 child loses it. +#[test] +fn strip_task_tools_honors_spawn_depth() { + use xai_grok_agent::config::AgentDefinition; + use xai_grok_tools::registry::types::ToolServerConfig; + use xai_grok_tools::types::tool::ToolKind; + use super::super::handle_request::strip_task_tools_at_max_depth; + let has_task = |cfg: &ToolServerConfig| { + cfg.tools.iter().any(|tc| tc.kind == Some(ToolKind::Task)) + }; + let base = AgentDefinition::general_purpose().tool_config; + assert!(has_task(& base)); + let mut natural_child = base.clone(); + assert!(strip_task_tools_at_max_depth(& mut natural_child, 1)); + assert!(! has_task(& natural_child)); + let mut loop_iteration = base.clone(); + assert!(! strip_task_tools_at_max_depth(& mut loop_iteration, 0)); + assert!(has_task(& loop_iteration)); +} diff --git a/crates/codegen/xai-grok-shell/src/extensions/hooks.rs b/crates/codegen/xai-grok-shell/src/extensions/hooks.rs index 7c9fdb8..569cac9 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/hooks.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/hooks.rs @@ -88,8 +88,7 @@ pub struct ClientHookGroup { /// `None` (wire `null`, `""`, or `"*"`) matches every tool. pub matcher: Option, pub callback_ids: Vec, - /// Per-group reply deadline for the `PreToolUse` gate (wire value in seconds). `None` - /// falls back to the default gate timeout. + /// Per-group gate reply deadline (wire seconds); `None` uses the default. pub timeout: Option, } @@ -107,12 +106,24 @@ pub(crate) struct ClientHookDispatch<'a> { pub envelope: &'a HookEventEnvelope, } -/// Only `Deny` blocks the tool; every other value proceeds (fail-open). +pub(crate) const ADVERTISED_BLOCKING_EVENTS: &[xai_grok_hooks::event::HookEventName] = &[ + xai_grok_hooks::event::HookEventName::PreToolUse, + xai_grok_hooks::event::HookEventName::Stop, + xai_grok_hooks::event::HookEventName::SubagentStop, +]; + +pub(crate) const ADVERTISED_DECISIONS: &[&str] = &["deny", "block"]; + +pub(crate) const ADVERTISED_STOP_SIGNALS: &[&str] = + &["continue", "stopReason", "additionalContext"]; + +/// Only `Deny` blocks; every other value proceeds (fail-open). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ClientHookDecision { #[default] Continue, + #[serde(alias = "block")] Deny, #[serde(other)] Other, @@ -125,9 +136,14 @@ pub(crate) enum ClientHookDecision { pub(crate) struct ClientHookResponse { #[serde(default)] pub decision: ClientHookDecision, - /// Deny reason surfaced to the model/user; consumed only when `decision` is `Deny`. - #[serde(default)] + #[serde(default, alias = "reason")] pub system_message: Option, + #[serde(default, rename = "continue")] + pub continue_: Option, + #[serde(default)] + pub stop_reason: Option, + #[serde(default)] + pub additional_context: Option, } /// Parse client hooks from `session/new` `_meta["x.ai/hooks"]`, shaped @@ -199,7 +215,7 @@ fn parse_hook_group(event: HookEventName, value: &serde_json::Value) -> Option 0.0) @@ -208,6 +224,14 @@ fn parse_hook_group(event: HookEventName, value: &serde_json::Value) -> Option None, + // Same policy as file hooks (`MatcherPolicy::Ignored`): warn and drop + // the matcher rather than let the registration appear scoped. + Some(pattern) + if event.traits().matcher == xai_grok_hooks::event::MatcherPolicy::Ignored => + { + tracing::warn!(%event, pattern, "matcher on a {event} hook group is ignored (this event always fires)"); + None + } Some(pattern) => match HookMatcher::new(pattern) { Ok(matcher) => Some(matcher), Err(err) => { @@ -268,7 +292,7 @@ mod tests { HookSpec { name: "test:pre_tool_use[0].hooks[0]".to_string(), event: HookEventName::PreToolUse, - handler_type: "command".to_string(), + handler_type: xai_grok_hooks::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, @@ -380,7 +404,7 @@ mod tests { assert_eq!(groups[0].timeout, Some(std::time::Duration::from_secs(5))); assert_eq!(groups[1].timeout, None); // non-positive -> default assert_eq!(groups[2].timeout, None); // absent -> default - assert_eq!(groups[3].timeout, Some(std::time::Duration::from_secs(300))); // capped + assert_eq!(groups[3].timeout, Some(std::time::Duration::from_secs(600))); // capped } /// A registration under the `SubagentEnd` alias must land on the canonical @@ -435,6 +459,66 @@ mod tests { ClientHookResponse::default().decision, ClientHookDecision::Continue ); + + let stop: ClientHookResponse = serde_json::from_str( + r#"{"continue":false,"stopReason":"budget","additionalContext":"ctx"}"#, + ) + .unwrap(); + assert_eq!(stop.decision, ClientHookDecision::Continue); + assert_eq!(stop.continue_, Some(false)); + assert_eq!(stop.stop_reason.as_deref(), Some("budget")); + assert_eq!(stop.additional_context.as_deref(), Some("ctx")); + + // Literal stop-hook output parses on the raw wire: `block` aliases + // `deny` and `reason` aliases `systemMessage`. + let blocked: ClientHookResponse = + serde_json::from_str(r#"{"decision":"block","reason":"run the tests"}"#).unwrap(); + assert_eq!(blocked.decision, ClientHookDecision::Deny); + assert_eq!(blocked.system_message.as_deref(), Some("run the tests")); + } + + #[test] + fn advertised_blocking_events_are_gates() { + use xai_grok_hooks::event::GateKind; + for event in ADVERTISED_BLOCKING_EVENTS { + assert_ne!( + event.traits().gate, + GateKind::Observe, + "advertised blocking event {event:?} has no decision gate" + ); + } + } + + #[test] + fn advertised_capabilities_match_response_parser() { + for decision in ADVERTISED_DECISIONS { + let parsed: ClientHookDecision = + serde_json::from_value(serde_json::json!(decision)).unwrap(); + assert_eq!( + parsed, + ClientHookDecision::Deny, + "advertised decision {decision:?} must parse as a blocking decision" + ); + } + + let signal_values = serde_json::json!({ + "continue": false, + "stopReason": "r", + "additionalContext": "c", + }); + for signal in ADVERTISED_STOP_SIGNALS { + let response: ClientHookResponse = serde_json::from_value( + serde_json::json!({ *signal: signal_values[*signal].clone() }), + ) + .unwrap(); + let captured = match *signal { + "continue" => response.continue_ == Some(false), + "stopReason" => response.stop_reason.as_deref() == Some("r"), + "additionalContext" => response.additional_context.as_deref() == Some("c"), + other => panic!("unknown advertised stop signal {other:?}"), + }; + assert!(captured, "advertised stop signal {signal:?} was not parsed"); + } } /// The callback id sits beside the flattened envelope (camelCase keys, @@ -452,12 +536,12 @@ mod tests { transcript_path: None, client_identifier: None, prompt_id: None, + permission_mode: Some("default".into()), payload: HookPayload::PreToolUse { tool_name: "run_terminal_command".into(), tool_use_id: "call_1".into(), tool_input: serde_json::json!({ "command": "ls" }), tool_input_truncated: true, - permission_mode: None, subagent_type: None, }, }; @@ -474,5 +558,6 @@ mod tests { assert_eq!(value["toolName"], "run_terminal_command"); assert_eq!(value["toolInput"]["command"], "ls"); assert_eq!(value["toolInputTruncated"], true); + assert_eq!(value["permissionMode"], "default"); } } diff --git a/crates/codegen/xai-grok-shell/src/extensions/mod.rs b/crates/codegen/xai-grok-shell/src/extensions/mod.rs index a206ecb..5c94fbe 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/mod.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/mod.rs @@ -29,6 +29,7 @@ pub mod routing; pub mod search; pub mod session_admin; pub mod session_search; +pub mod session_state; pub mod session_updates; pub mod share; pub mod skills; diff --git a/crates/codegen/xai-grok-shell/src/extensions/notification.rs b/crates/codegen/xai-grok-shell/src/extensions/notification.rs index e238ffa..85253a8 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/notification.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/notification.rs @@ -341,9 +341,18 @@ pub fn attach_result_usage_fail_closed(result: &mut serde_json::Value, usage: &s #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(rename_all = "camelCase", tag = "status")] pub enum HookRunStatusDto { - Success { elapsed_ms: u64 }, + Success { + elapsed_ms: u64, + }, Skipped, - Failed { error: String, elapsed_ms: u64 }, + Failed { + error: String, + elapsed_ms: u64, + /// Stop-gate block (the hook's decision, not a failure). Rides `failed` + /// so old pagers keep rendering it. TODO: promote to a dedicated status. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + blocked: bool, + }, } /// A single hook run entry (wire format). @@ -461,7 +470,6 @@ pub enum SessionUpdate { HookExecution { /// The hook event name ("pre_tool_use" or "post_tool_use"). event_name: String, - /// The tool name this hook is associated with. #[serde(default, skip_serializing_if = "Option::is_none")] tool_name: Option, /// The prompt turn this batch belongs to, when known; lets the @@ -469,7 +477,6 @@ pub enum SessionUpdate { /// turn's marker. #[serde(default, skip_serializing_if = "Option::is_none")] prompt_id: Option, - /// Individual hook run results. runs: Vec, }, /// Hooks registry changed (after reload or trust/untrust). @@ -691,6 +698,8 @@ pub enum SessionUpdate { prompt: String, human_schedule: String, next_fire_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent_id: Option, }, /// A scheduled task was deleted/cancelled. ScheduledTaskDeleted { task_id: String }, diff --git a/crates/codegen/xai-grok-shell/src/extensions/session_state.rs b/crates/codegen/xai-grok-shell/src/extensions/session_state.rs new file mode 100644 index 0000000..ad52292 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/extensions/session_state.rs @@ -0,0 +1,309 @@ +//! `x.ai/session/state` reads a session's metadata columns; `x.ai/session/import` +//! writes them, with the transcript, to recreate a session on another host. + +use std::path::{Path, PathBuf}; + +use agent_client_protocol as acp; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::ExtResult; +use crate::session::persistence::Summary; +use crate::session::storage as st; + +/// The summary column, required to load a session. +const SUMMARY_COLUMN: &str = "summary"; + +/// Logical column name to its file under the session directory. Paths come from the +/// storage layer so import and load never disagree about the on-disk layout. `summary` +/// is last so import writes it last, as the commit marker; keep it there. +const COLUMNS: &[(&str, &str)] = &[ + ("plan", st::PLAN_FILE), + ("planMode", st::PLAN_MODE_FILE), + ("signals", st::SIGNALS_FILE), + ("goal", st::GOAL_STATE_FILE), + ("announcement", st::ANNOUNCEMENT_STATE_FILE), + (SUMMARY_COLUMN, st::SUMMARY_FILE), +]; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct StateRequest { + session_id: String, + cwd: String, +} + +/// A session id is a UUID (see acp_agent's new_session); requiring that keeps it safe +/// to join into a filesystem path. +fn validate_session_uuid(session_id: &str) -> Result<(), acp::Error> { + uuid::Uuid::try_parse(session_id) + .map(|_| ()) + .map_err(|_| acp::Error::invalid_params().data("sessionId must be a UUID")) +} + +/// `x.ai/session/state`: return metadata columns keyed by logical name. Errors when +/// the session isn't found on this host, since it reads a single record whose absence +/// is not an empty result (unlike the collection returned by `x.ai/session/updates`). +pub async fn handle_state(args: &acp::ExtRequest) -> ExtResult { + let request: StateRequest = super::parse_params(args)?; + validate_session_uuid(&request.session_id)?; + + let Some(dir) = resolve_session_dir(&request.session_id, &request.cwd) else { + return Err(acp::Error::invalid_params().data("session not found")); + }; + let mut state = serde_json::Map::new(); + for (column, rel) in COLUMNS { + if let Ok(text) = std::fs::read_to_string(dir.join(rel)) + && let Ok(value) = serde_json::from_str::(&text) + { + state.insert((*column).to_string(), value); + } + } + super::to_raw_response(&state) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImportRequest { + session_id: String, + cwd: String, + #[serde(default)] + state: std::collections::HashMap, + /// One JSON object per `updates.jsonl` line, not pre-serialized strings. + #[serde(default)] + updates: Vec, +} + +/// `x.ai/session/import`: recreate a session on this host from mirrored columns and +/// transcript. A session that already exists locally is left unchanged. +pub async fn handle_import(args: &acp::ExtRequest) -> ExtResult { + let mut request: ImportRequest = super::parse_params(args)?; + validate_session_uuid(&request.session_id)?; + + let info = crate::session::info::Info { + id: acp::SessionId::new(request.session_id.clone()), + cwd: request.cwd.clone(), + }; + let dir = crate::session::persistence::session_dir(&info); + + // resolve_session_dir gates on summary.json, so an interrupted import (dir created, + // summary not yet written) is recreated on retry rather than skipped forever. + let has_local_session = resolve_session_dir(&request.session_id, &request.cwd).is_some(); + if !has_local_session { + let Some(summary_value) = request.state.get_mut(SUMMARY_COLUMN) else { + return Err( + acp::Error::invalid_params().data("session/import requires a summary column") + ); + }; + let Some(summary) = summary_value.as_object_mut() else { + return Err( + acp::Error::invalid_params().data("session/import summary must be an object") + ); + }; + sanitize_summary_for_host(summary, &request.session_id, &request.cwd); + // Reject a summary that would not load rather than persist one that bricks the + // session and blocks re-import. + if Summary::deserialize(&*summary_value).is_err() { + return Err(acp::Error::invalid_params().data("summary column is not a valid summary")); + } + // Write the `.cwd` sidecar for hash-based (long-path) dirs so the session stays + // recoverable by id, not just by (id, cwd). + crate::util::grok_home::ensure_sessions_cwd_dir(&request.cwd) + .map_err(|e| acp::Error::internal_error().data(e.to_string()))?; + write_import(&dir, &request.state, &request.updates) + .map_err(|e| acp::Error::internal_error().data(e.to_string()))?; + } + super::to_raw_response(&json!({ "imported": !has_local_session })) +} + +/// Rewrite a mirrored summary's host-specific fields to describe this host. +fn sanitize_summary_for_host(summary: &mut serde_json::Map, id: &str, cwd: &str) { + if let Some(info_obj) = summary.get_mut("info").and_then(Value::as_object_mut) { + info_obj.insert("id".to_string(), Value::String(id.to_string())); + info_obj.insert("cwd".to_string(), Value::String(cwd.to_string())); + } + summary.insert( + "chat_format_version".to_string(), + json!(crate::session::persistence::CHAT_FORMAT_VERSION), + ); + summary.insert("git_remotes".to_string(), json!([])); + for field in [ + "prompt_display_cwd", + "source_workspace_dir", + "git_root_dir", + "head_commit", + "head_branch", + "worktree_label", + "request_id", + ] { + summary.remove(field); + } + set_or_remove( + summary, + "grok_home", + crate::session::persistence::grok_home_string(), + ); + set_or_remove( + summary, + "sandbox_profile", + xai_grok_sandbox::configured_profile_name().map(String::from), + ); +} + +fn set_or_remove(obj: &mut serde_json::Map, key: &str, value: Option) { + match value { + Some(v) => { + obj.insert(key.to_string(), Value::String(v)); + } + None => { + obj.remove(key); + } + } +} + +/// Writes summary.json last, and each file to a temporary name first, so an interrupted +/// import leaves an incomplete session that load treats as absent. +fn write_import( + dir: &Path, + state: &std::collections::HashMap, + updates: &[Value], +) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + + // Clear every file this import owns so a leftover from a failed attempt can't + // merge with the new snapshot; this import is authoritative. + let _ = std::fs::remove_file(dir.join(st::CHAT_HISTORY_FILE)); + let _ = std::fs::remove_file(dir.join(st::UPDATES_FILE)); + for (_, rel) in COLUMNS { + let _ = std::fs::remove_file(dir.join(rel)); + } + + if !updates.is_empty() { + st::write_jsonl_atomic(&dir.join(st::UPDATES_FILE), updates)?; + } + + for (column, rel) in COLUMNS { + if let Some(value) = state.get(*column) { + write_column(dir, rel, value)?; + } + } + Ok(()) +} + +fn write_column(dir: &Path, rel: &str, value: &Value) -> std::io::Result<()> { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + st::write_bytes_atomic(&path, value.to_string().as_bytes()) +} + +/// The session's directory, or `None` when it isn't found on this host. Falls back to +/// an id scan when `(id, cwd)` has no summary (subagents use their own cwd); both +/// branches require summary.json so a bare directory doesn't count as present. +fn resolve_session_dir(session_id: &str, cwd: &str) -> Option { + let info = crate::session::info::Info { + id: acp::SessionId::new(session_id.to_string()), + cwd: cwd.to_string(), + }; + let dir = crate::session::persistence::session_dir(&info); + if dir.join(st::SUMMARY_FILE).is_file() { + return Some(dir); + } + crate::session::persistence::find_session_dir_by_id(session_id) + .filter(|found| found.join(st::SUMMARY_FILE).is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sanitize_summary_for_host_rewrites_host_fields() { + let mut summary = json!({ + "info": { "id": "s1", "cwd": "/remote/host/work" }, + "chat_format_version": 0, + "prompt_display_cwd": "/remote/host/work", + "source_workspace_dir": "/remote/host", + "git_root_dir": "/remote/host/repo", + "git_remotes": ["origin"], + "head_commit": "deadbeef", + "head_branch": "feature", + "worktree_label": "wt", + "request_id": "req-1", + }) + .as_object() + .unwrap() + .clone(); + + sanitize_summary_for_host(&mut summary, "s-new", "/local/work"); + + assert_eq!(summary["info"]["id"], json!("s-new")); + assert_eq!(summary["info"]["cwd"], json!("/local/work")); + assert_eq!( + summary["chat_format_version"], + json!(crate::session::persistence::CHAT_FORMAT_VERSION) + ); + assert_eq!(summary["git_remotes"], json!([])); + for gone in [ + "prompt_display_cwd", + "source_workspace_dir", + "git_root_dir", + "head_commit", + "head_branch", + "worktree_label", + "request_id", + ] { + assert!(!summary.contains_key(gone), "{gone} should be dropped"); + } + } + + #[test] + fn write_import_writes_columns_updates_and_drops_stale_chat() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("chat_history.jsonl"), b"stale cache").unwrap(); + // A column left by a failed prior import that the new payload omits. + std::fs::write(dir.join("signals.json"), b"{\"stale\":true}").unwrap(); + + let mut state = std::collections::HashMap::new(); + state.insert( + "summary".to_string(), + json!({ "info": { "id": "s1", "cwd": "/work" } }), + ); + state.insert("plan".to_string(), json!({ "items": [] })); + state.insert("goal".to_string(), json!({ "active": false })); + let updates = vec![ + json!({ "method": "session/update", "params": { "a": 1 } }), + json!({ "method": "session/update", "params": { "b": 2 } }), + ]; + + write_import(dir, &state, &updates).unwrap(); + + assert!(dir.join("summary.json").exists(), "summary.json written"); + assert_eq!( + std::fs::read_to_string(dir.join("plan.json")).unwrap(), + r#"{"items":[]}"# + ); + assert_eq!( + std::fs::read_to_string(dir.join("goal/state.json")).unwrap(), + r#"{"active":false}"# + ); + assert_eq!( + std::fs::read_to_string(dir.join("updates.jsonl")) + .unwrap() + .lines() + .count(), + 2 + ); + assert!( + !dir.join("chat_history.jsonl").exists(), + "stale chat cache dropped so load rebuilds" + ); + assert!( + !dir.join("signals.json").exists(), + "orphan column from a failed import dropped" + ); + } +} diff --git a/crates/codegen/xai-grok-shell/src/extensions/session_updates.rs b/crates/codegen/xai-grok-shell/src/extensions/session_updates.rs index 1bffc26..80e3bad 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/session_updates.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/session_updates.rs @@ -32,6 +32,8 @@ //! notification params. Clients should parse the `method` field to determine //! the update type (`"session/update"` for ACP, `"_x.ai/session/update"` for //! xAI extensions) and extract the notification payload from `params`. +//! +//! Metadata columns and cross-host import live in [`crate::extensions::session_state`]. use std::io::{self, BufRead, BufReader}; use std::path::Path; @@ -344,8 +346,8 @@ pub async fn handle( id: acp::SessionId::new(request.session_id.clone()), cwd: request.cwd.clone(), }; - let session_dir = crate::session::persistence::session_dir(&session_info); - let mut updates_path = session_dir.join("updates.jsonl"); + let mut updates_path = crate::session::persistence::session_dir(&session_info) + .join(crate::session::storage::UPDATES_FILE); // Subagents persist under their own cwd (may differ from the parent cwd // passed here), so fall back to an id scan when the (id, cwd) path misses. @@ -353,7 +355,7 @@ pub async fn handle( && let Some(found_dir) = crate::session::persistence::find_session_dir_by_id(&request.session_id) { - let candidate = found_dir.join("updates.jsonl"); + let candidate = found_dir.join(crate::session::storage::UPDATES_FILE); if candidate.exists() { updates_path = candidate; } diff --git a/crates/codegen/xai-grok-shell/src/inspect/mod.rs b/crates/codegen/xai-grok-shell/src/inspect/mod.rs index 78bff95..899fcb7 100644 --- a/crates/codegen/xai-grok-shell/src/inspect/mod.rs +++ b/crates/codegen/xai-grok-shell/src/inspect/mod.rs @@ -694,7 +694,7 @@ fn list_hooks( let vendor = derive_vendor(&h.source_dir.display().to_string()).map(String::from); HookEntry { event: format!("{:?}", h.event), - hook_type: h.handler_type.clone(), + hook_type: h.handler_type.as_str().to_string(), target: h .command .as_ref() diff --git a/crates/codegen/xai-grok-shell/src/remote/pull.rs b/crates/codegen/xai-grok-shell/src/remote/pull.rs index 4564347..d7b89be 100644 --- a/crates/codegen/xai-grok-shell/src/remote/pull.rs +++ b/crates/codegen/xai-grok-shell/src/remote/pull.rs @@ -55,6 +55,7 @@ pub(crate) mod hydrate { use crate::remote::client::{BackendError, LoadDataResponse, LoadedMessage, SessionInfo}; use crate::session::info::Info; use crate::session::persistence::{CHAT_FORMAT_VERSION, Summary, default_model_id}; + use crate::session::storage::{SUMMARY_FILE, UPDATES_FILE}; fn io_err(path: &Path, source: std::io::Error) -> BackendError { BackendError::Hydration { @@ -85,7 +86,8 @@ pub(crate) mod hydrate { if let Some(ref messages) = loaded.messages { write_updates(dir, messages)?; - num_chat_messages = rebuild_chat_history(dir)?; + num_chat_messages = crate::session::storage::chat_rebuild::rebuild_chat_history(dir) + .map_err(|e| io_err(dir, e))?; } write_summary(dir, &info, remote, num_messages, num_chat_messages)?; @@ -153,7 +155,7 @@ pub(crate) mod hydrate { }; let json = serde_json::to_string_pretty(&summary)?; - write_file(&dir.join("summary.json"), json.as_bytes()) + write_file(&dir.join(SUMMARY_FILE), json.as_bytes()) } /// Convert backend JSON-RPC messages to local updates.jsonl (replayable methods only). @@ -163,7 +165,7 @@ pub(crate) mod hydrate { ) -> Result<(), BackendError> { use std::io::Write; - let path = dir.join("updates.jsonl"); + let path = dir.join(UPDATES_FILE); let file = std::fs::File::create(&path).map_err(|e| io_err(&path, e))?; let mut w = std::io::BufWriter::new(file); @@ -184,326 +186,6 @@ pub(crate) mod hydrate { w.flush().map_err(|e| io_err(&path, e)) } - /// Rebuild `chat_history.jsonl` from `updates.jsonl` so pulled sessions are continuable. - fn rebuild_chat_history(dir: &Path) -> Result { - use crate::session::storage::UpdatesIterator; - use std::io::{Seek, Write}; - - let updates_path = dir.join("updates.jsonl"); - let Some(iter) = - UpdatesIterator::open(&updates_path).map_err(|e| io_err(&updates_path, e))? - else { - return Ok(0); - }; - - let chat_path = dir.join("chat_history.jsonl"); - let file = std::fs::File::create(&chat_path).map_err(|e| io_err(&chat_path, e))?; - let mut writer = std::io::BufWriter::new(file); - let mut reducer = ChatReducer::new(); - - for result in iter { - let update = match result { - Ok(u) => u, - Err(_) => continue, - }; - - for item in reducer.process(&update) { - if let Ok(line) = serde_json::to_string(&item) { - let _ = writer.write_all(line.as_bytes()); - let _ = writer.write_all(b"\n"); - } - } - - // CompactionCheckpoint: truncate file and reset - if reducer.should_truncate() { - reducer.clear_truncate_flag(); - let _ = writer.seek(std::io::SeekFrom::Start(0)); - let _ = writer.get_mut().set_len(0); - } - } - - // Flush trailing state - for item in reducer.flush() { - if let Ok(line) = serde_json::to_string(&item) { - let _ = writer.write_all(line.as_bytes()); - let _ = writer.write_all(b"\n"); - } - } - - writer.flush().map_err(|e| io_err(&chat_path, e))?; - Ok(reducer.count()) - } - - use crate::sampling::{AssistantItem, ContentPart, ConversationItem, ToolCall}; - use agent_client_protocol as acp; - use std::collections::{HashMap, HashSet}; - - /// Reduces ACP session updates into conversation items. - /// - /// Turn boundaries: User→Agent flushes user, Agent→User flushes agent, - /// tool completion flushes agent before emitting result. - struct ChatReducer { - user_parts: Vec, - agent_text: String, - agent_tool_calls: Vec, - - in_user_turn: bool, - has_agent_content: bool, - needs_truncate: bool, - - tool_args: HashMap, - emitted_tool_results: HashSet, - item_count: usize, - } - - impl ChatReducer { - fn new() -> Self { - Self { - user_parts: Vec::new(), - agent_text: String::new(), - agent_tool_calls: Vec::new(), - in_user_turn: false, - has_agent_content: false, - needs_truncate: false, - tool_args: HashMap::new(), - emitted_tool_results: HashSet::new(), - item_count: 0, - } - } - - fn process( - &mut self, - update: &crate::session::storage::SessionUpdate, - ) -> Vec { - use crate::session::storage::SessionUpdate; - - match update { - SessionUpdate::Acp(n) => self.handle_acp(&n.update), - SessionUpdate::Xai(n) => self.handle_xai(&n.update), - } - } - - fn handle_acp(&mut self, update: &acp::SessionUpdate) -> Vec { - match update { - acp::SessionUpdate::UserMessageChunk(chunk) => self.on_user_chunk(chunk), - acp::SessionUpdate::AgentMessageChunk(chunk) => self.on_agent_chunk(chunk), - acp::SessionUpdate::ToolCall(tc) => self.on_tool_call(tc), - acp::SessionUpdate::ToolCallUpdate(tc) => self.on_tool_call_update(tc), - _ => Vec::new(), // AgentThoughtChunk, Retry, Plan not needed - } - } - - fn handle_xai( - &mut self, - update: &crate::extensions::notification::SessionUpdate, - ) -> Vec { - use crate::extensions::notification::SessionUpdate as XaiUpdate; - - match update { - XaiUpdate::CompactionCheckpoint(_) => { - self.reset(); - self.needs_truncate = true; - Vec::new() - } - _ => Vec::new(), // DiffReview, MemoryFlush, etc. not needed - } - } - - fn on_user_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec { - let mut out = Vec::new(); - - if !self.in_user_turn { - out.extend(self.flush_agent()); - self.in_user_turn = true; - } - - match &chunk.content { - acp::ContentBlock::Text(t) => { - self.user_parts.push(ContentPart::Text { - text: std::sync::Arc::::from(t.text.clone()), - }); - } - acp::ContentBlock::Image(img) => { - if let Some(uri) = &img.uri { - self.user_parts.push(ContentPart::Image { - url: std::sync::Arc::::from(uri.clone()), - }); - } - } - _ => {} // Audio, Resource, etc. not needed for chat replay - } - - out - } - - fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec { - let mut out = Vec::new(); - - if self.in_user_turn { - out.extend(self.flush_user()); - self.in_user_turn = false; - } - - if let acp::ContentBlock::Text(t) = &chunk.content { - self.agent_text.push_str(&t.text); - self.has_agent_content = true; - } - - out - } - - fn on_tool_call(&mut self, tc: &acp::ToolCall) -> Vec { - let id = tc.tool_call_id.0.to_string(); - let args = tc - .raw_input - .as_ref() - .map(|v| v.to_string()) - .unwrap_or_default(); - - self.tool_args.insert(id.clone(), args.clone()); - self.agent_tool_calls.push(ToolCall { - id: std::sync::Arc::::from(id), - name: tc.title.clone(), - arguments: std::sync::Arc::::from(args), - }); - - Vec::new() - } - - fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec { - let id = tc.tool_call_id.0.to_string(); - self.maybe_backfill_args(&id, &tc.fields); - - if Self::is_completed(&tc.fields) && self.emitted_tool_results.insert(id.clone()) { - return self.emit_tool_result(&id, &tc.fields); - } - Vec::new() - } - - /// Backfill tool arguments from ToolCallUpdate if ToolCall didn't have them. - fn maybe_backfill_args(&mut self, id: &str, fields: &acp::ToolCallUpdateFields) { - let Some(raw) = &fields.raw_input else { return }; - let needs_backfill = self.tool_args.get(id).is_none_or(String::is_empty); - if !needs_backfill { - return; - } - - let args = raw.to_string(); - self.tool_args.insert(id.to_string(), args.clone()); - - if let Some(call) = self - .agent_tool_calls - .iter_mut() - .find(|c| c.id.as_ref() == id) - { - call.arguments = std::sync::Arc::::from(args); - } - } - - fn is_completed(fields: &acp::ToolCallUpdateFields) -> bool { - matches!( - fields.status, - Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) - ) - } - - fn emit_tool_result( - &mut self, - id: &str, - fields: &acp::ToolCallUpdateFields, - ) -> Vec { - let mut out = Vec::new(); - out.extend(self.flush_agent()); - - let content = extract_tool_result_text(fields); - let item = ConversationItem::tool_result(id.to_string(), content); - self.item_count += 1; - out.push(item); - out - } - - fn flush_user(&mut self) -> Option { - if self.user_parts.is_empty() { - return None; - } - let item = ConversationItem::user_with_parts(std::mem::take(&mut self.user_parts)); - self.item_count += 1; - Some(item) - } - - fn flush_agent(&mut self) -> Option { - if !self.has_agent_content && self.agent_tool_calls.is_empty() { - return None; - } - let item = ConversationItem::Assistant(AssistantItem { - content: std::sync::Arc::::from(std::mem::take(&mut self.agent_text)), - tool_calls: std::mem::take(&mut self.agent_tool_calls), - model_id: None, - model_fingerprint: None, - reasoning_effort: None, - }); - self.has_agent_content = false; - self.item_count += 1; - Some(item) - } - - fn flush(&mut self) -> Vec { - let mut out = Vec::new(); - out.extend(self.flush_user()); - out.extend(self.flush_agent()); - out - } - - fn reset(&mut self) { - self.user_parts.clear(); - self.agent_text.clear(); - self.agent_tool_calls.clear(); - self.tool_args.clear(); - self.emitted_tool_results.clear(); - self.in_user_turn = false; - self.has_agent_content = false; - self.item_count = 0; - } - - fn should_truncate(&self) -> bool { - self.needs_truncate - } - - fn clear_truncate_flag(&mut self) { - self.needs_truncate = false; - } - - fn count(&self) -> usize { - self.item_count - } - } - - /// Extract displayable text from a completed ToolCallUpdate. - fn extract_tool_result_text(fields: &agent_client_protocol::ToolCallUpdateFields) -> String { - if let Some(content) = &fields.content { - let text: String = content - .iter() - .filter_map(|c| match c { - agent_client_protocol::ToolCallContent::Content( - agent_client_protocol::Content { - content: agent_client_protocol::ContentBlock::Text(t), - .. - }, - ) => Some(t.text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - if !text.is_empty() { - return text; - } - } - if let Some(raw) = &fields.raw_output { - return raw.to_string(); - } - String::new() - } - fn write_remote_origin_marker(dir: &Path) { let _ = std::fs::write( dir.join(".remote_origin"), diff --git a/crates/codegen/xai-grok-shell/src/sampling/error.rs b/crates/codegen/xai-grok-shell/src/sampling/error.rs index 0c4e626..a6725dc 100644 --- a/crates/codegen/xai-grok-shell/src/sampling/error.rs +++ b/crates/codegen/xai-grok-shell/src/sampling/error.rs @@ -129,6 +129,8 @@ pub fn map_sampling_err_to_acp(err: SamplingError) -> acp::Error { } else { message }; + // 403 is content-safety, never auth: on this setup path it stays + // `internal_error` → `server_error`. acp::Error::internal_error().data(message) } StatusCode::BAD_REQUEST => acp::Error::invalid_params().data(message), @@ -137,7 +139,10 @@ pub fn map_sampling_err_to_acp(err: SamplingError) -> acp::Error { StatusCode::TOO_MANY_REQUESTS => { acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited".to_string()).data(message) } - _ => acp::Error::internal_error().data(message), + // Preserve the HTTP status in data so the classifier folds capacity + // errors (503/529) into `rate_limit`. + _ => acp::Error::internal_error() + .data(error_data_with_status(message, Some(status.as_u16()))), }, SamplingError::EventStreamError(message) => acp::Error::internal_error().data(message), SamplingError::StreamError { @@ -540,6 +545,20 @@ mod tests { assert_eq!(server_acp.code, acp::Error::internal_error().code); } + #[test] + fn service_unavailable_retains_http_status_for_classification() { + let err = SamplingError::Api { + status: StatusCode::SERVICE_UNAVAILABLE, + message: "at capacity".into(), + model_metadata: None, + retry_after_secs: None, + should_retry: None, + }; + let acp_err = map_sampling_err_to_acp(err); + assert_eq!(acp_err.code, acp::Error::internal_error().code); + assert_eq!(http_status_from_error(&acp_err), Some(503)); + } + #[test] fn auth_errors_map_to_auth_required() { let err = SamplingError::Api { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session.rs b/crates/codegen/xai-grok-shell/src/session/acp_session.rs index 63ffbd2..9d63bca 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session.rs @@ -161,6 +161,9 @@ pub(crate) use goal_support::*; #[path = "acp_session_impl/hook_dispatch.rs"] mod hook_dispatch; use hook_dispatch::*; +#[path = "acp_session_impl/stop_gate.rs"] +mod stop_gate; +pub use stop_gate::MAX_STOP_HOOK_CONTINUATIONS_PER_TURN; #[path = "acp_session_impl/recap.rs"] mod recap; #[path = "acp_session_impl/rewind.rs"] @@ -557,13 +560,6 @@ impl PreparedToolCall { #[cfg(test)] pub(crate) use crate::session::streaming_capture::STREAMING_CAPTURE_MAX_BYTES; pub(crate) use crate::session::streaming_capture::StreamingTurnCapture; -/// Spawn-time metadata for a subagent, kept by `subagent_id` so the `SubagentStop` event -/// (whose notification carries neither) can report the subagent's type and description. -#[derive(Clone)] -pub(crate) struct SubagentSpawnInfo { - pub description: String, - pub subagent_type: String, -} /// Phase 3: Post-flight handling after dispatch (inline in execute_tool_calls for now). pub(crate) struct SessionActor { pub(crate) session_info: SessionInfo, @@ -1033,9 +1029,6 @@ pub(crate) struct SessionActor { pub(crate) image_description_model: String, /// Cache auxiliary image outputs by content and prompt fingerprint. pub(crate) image_describe_cache: Arc, - /// [`SubagentSpawnInfo`] by `subagent_id`: inserted on `SubagentSpawned`, removed on - /// `SubagentFinished`. - pub(crate) subagent_spawn_info: parking_lot::Mutex>, /// Per-subagent token state keyed by `subagent_id`; sums into /// goal totals via [`Self::goal_tokens`]. pub(crate) subagent_token_records: parking_lot::Mutex>, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session/hooks.rs b/crates/codegen/xai-grok-shell/src/session/acp_session/hooks.rs index bd6b389..3e69423 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session/hooks.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session/hooks.rs @@ -3,11 +3,17 @@ //! Hooks registered at `session/new` (`_meta["x.ai/hooks"]`) come in two flavors, //! both matched by the agent ([`xai_grok_hooks::matcher::HookMatcher`], shared with //! file hooks): -//! - **`PreToolUse` gate**: an awaited reverse *request* `x.ai/hooks/run`; a `deny` -//! blocks the tool. +//! - **Gates** (awaited reverse *requests* `x.ai/hooks/run`): +//! - `PreToolUse`: a `deny` blocks the tool. +//! - `Stop` / `SubagentStop` (turn-end gate): a `deny` blocks the agent from +//! stopping (its `systemMessage` becomes the feedback), `continue: false` +//! (+ `stopReason`) force-stops overriding blocks, and `additionalContext` +//! keeps the agent working with non-error feedback: the same vocabulary +//! file hooks produce, aggregated in [`Self::run_stop_client_hooks`]. //! - **All other events**: fire-and-forget *notifications* `x.ai/hooks/event`, //! observe-only (the callback's return is ignored). Sent per matching callback. +use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -27,16 +33,16 @@ use crate::sampling::types::ToolCallResponse; const HOOK_EVENT_METHOD: &str = "x.ai/hooks/event"; const HOOK_RUN_METHOD: &str = "x.ai/hooks/run"; -/// Default per-callback bound for a client's `x.ai/hooks/run` reply; on timeout the gate -/// fails open (the tool proceeds). -/// -/// Some external hosts default to 600s per hook; we default to 30s because our gate sits -/// in the interactive tool hot path (a hung hook would otherwise stall a tool call for -/// minutes). Hosts can override per group up to `MAX_HOOK_TIMEOUT_SECS` (300s). To match -/// a longer external default, change this value (and raise/remove the cap in -/// `extensions::hooks`). +/// Default reply deadline for the `PreToolUse` client gate: short because it +/// sits in the interactive tool hot path. On timeout the gate fails open (the +/// tool proceeds). Stop gates use `CLIENT_STOP_GATE_TIMEOUT` instead. const CLIENT_HOOK_TIMEOUT: Duration = Duration::from_secs(30); +/// Default reply deadline for the `Stop`/`SubagentStop` client gate. A +/// timed-out gate fails open (the agent stops), so too short a default would +/// silently drop a ported goal policy that runs a build or test suite. +const CLIENT_STOP_GATE_TIMEOUT: Duration = Duration::from_secs(600); + /// Outcome of the `x.ai/hooks/run` reverse request, before interpreting it as a /// decision. Separate so [`classify`] stays pure and unit-testable. enum ReverseOutcome { @@ -92,25 +98,16 @@ fn classify(outcome: ReverseOutcome) -> (ClientHookResponse, ClientHookGateOutco } } -/// Whether `group` fires for an event on `tool_name`. Mirrors the file-hook matcher rule -/// (dispatcher::dispatch_non_blocking): a group is skipped only when it has a matcher AND -/// there is a tool name AND the matcher doesn't match, so non-tool events -/// (`tool_name == None`) and matcher-less groups always fire. -fn group_matches(group: &ClientHookGroup, tool_name: Option<&str>) -> bool { - match (group.matcher.as_ref(), tool_name) { - (Some(matcher), Some(name)) => matcher.is_match(name), - _ => true, - } -} - /// Callback ids that fire for an event, in registration order. fn matching_callback_ids<'a>( groups: &'a [ClientHookGroup], - tool_name: Option<&str>, + match_value: Option<&str>, ) -> Vec<&'a str> { groups .iter() - .filter(|group| group_matches(group, tool_name)) + .filter(|group| { + xai_grok_hooks::matcher::matcher_allows(group.matcher.as_ref(), match_value) + }) .flat_map(|group| group.callback_ids.iter().map(String::as_str)) .collect() } @@ -128,7 +125,8 @@ fn dispatch_params(dispatch: &ClientHookDispatch<'_>) -> Option> { impl SessionActor { /// Build a [`HookEventEnvelope`] with this session's common fields filled (session id, /// cwd, workspace root, timestamp). Single source of truth for envelope shape; every - /// fire site goes through here. + /// fire site goes through here. The event name is canonicalized so alias + /// fire sites (`SubagentEnd`) serialize the canonical `hookEventName`. pub(super) fn make_hook_envelope( &self, hook_event_name: HookEventName, @@ -136,7 +134,7 @@ impl SessionActor { payload: HookPayload, ) -> HookEventEnvelope { HookEventEnvelope { - hook_event_name, + hook_event_name: hook_event_name.canonical(), session_id: self.session_id_string(), cwd: self.session_info.cwd.clone(), workspace_root: self.hook_workspace_root(), @@ -144,13 +142,16 @@ impl SessionActor { transcript_path: self.get_transcript_path(), client_identifier: None, prompt_id, + permission_mode: Some(self.permission_mode_label().to_string()), payload, } } - /// Whether any hook would consume `event`: the on-disk file registry, or a registered - /// client hook. Lets the hot path skip building/serializing a payload (e.g. a large tool - /// output) when nothing is listening, so the feature stays inert when unused. + /// Whether any hook source could consume `event`, letting the hot path skip + /// building a payload when nothing is listening. Deliberately coarse: any + /// on-disk registry activates every event (see + /// `has_enabled_hooks_for_canonical` for the precise check the stop gate + /// uses), while client hooks are checked per event. pub(super) fn hook_event_active(&self, event: HookEventName) -> bool { self.hook_registry.borrow().is_some() || self.client_hooks.borrow().contains_key(&event.canonical()) @@ -198,12 +199,66 @@ impl SessionActor { Ok(ToolLoop::HookDenied { hook_name }) } + /// Fan one `x.ai/hooks/run` gate dispatch out to every matching callback, + /// yielding `(callback_id, response)` in completion order. Independent + /// per-callback timeouts stop one slow callback starving another; timeout, + /// transport error, and malformed replies fail open per callback. + fn client_gate_responses<'a>( + &'a self, + groups: &'a [ClientHookGroup], + tool_name: Option<&'a str>, + envelope: &'a HookEventEnvelope, + ) -> FuturesUnordered + 'a> { + let default_timeout = + if envelope.hook_event_name.traits().gate == xai_grok_hooks::event::GateKind::Stop { + CLIENT_STOP_GATE_TIMEOUT + } else { + CLIENT_HOOK_TIMEOUT + }; + // Dedupe callback ids registered in multiple groups: one dispatch each. + let mut seen = std::collections::HashSet::new(); + groups + .iter() + .filter(move |group| { + xai_grok_hooks::matcher::matcher_allows(group.matcher.as_ref(), tool_name) + }) + .flat_map(move |group| { + let timeout = group.timeout.unwrap_or(default_timeout); + group + .callback_ids + .iter() + .map(move |callback_id| (callback_id.as_str(), timeout)) + }) + .filter(move |(callback_id, _)| seen.insert(*callback_id)) + .map(move |(callback_id, timeout)| { + let dispatch = ClientHookDispatch { + hook_callback_id: callback_id, + envelope, + }; + async move { + let started = tokio::time::Instant::now(); + let (response, gate_outcome) = + classify(self.send_hook_run(&dispatch, timeout).await); + let elapsed = started.elapsed(); + xai_grok_telemetry::session_ctx::log_event( + xai_grok_telemetry::events::ClientHookGate { + callback_id: callback_id.to_string(), + tool_name: tool_name.map(str::to_string), + outcome: gate_outcome, + duration_ms: elapsed.as_millis() as u64, + }, + ); + (callback_id, response, elapsed) + } + }) + .collect() + } + /// Run the client-registered `PreToolUse` hooks for `call`, firing /// `x.ai/hooks/run` once per matching callback with the shared `envelope` (the /// same payload file hooks and observe events receive). /// /// Returns `Some(ToolLoop::HookDenied)` on the first deny, else `None`. - /// Timeout, transport error, and malformed replies all fail open. pub(super) async fn run_pre_tool_use_client_hook( &self, call: &ToolCallResponse, @@ -223,51 +278,17 @@ impl SessionActor { // Match on the resolved target (in the envelope) so a client deny matcher // keyed on the real MCP tool gates a meta-dispatch call, matching the // observe path (`notify_client_hooks`). Equals `function.name` otherwise. - let tool_name = xai_grok_hooks::dispatcher::extract_tool_name(envelope) - .unwrap_or_else(|| call.function.name.clone()); - let tool_name = tool_name.as_str(); + let tool_name = envelope + .payload + .match_value() + .unwrap_or(call.function.name.as_str()); - // Dispatch every matching callback concurrently, each bounded by its group's - // timeout (else `CLIENT_HOOK_TIMEOUT`), and act on the first deny. Independent - // timeouts mean a slow or hung callback can't erode another's budget (so a later - // deny can't be starved into a fail-open), and concurrency keeps total gate latency - // bounded to ~one timeout regardless of count. - let mut pending: FuturesUnordered<_> = groups - .iter() - .filter(|group| group_matches(group, Some(tool_name))) - .flat_map(|group| { - let timeout = group.timeout.unwrap_or(CLIENT_HOOK_TIMEOUT); - group - .callback_ids - .iter() - .map(move |callback_id| (callback_id.as_str(), timeout)) - }) - .map(|(callback_id, timeout)| { - let dispatch = ClientHookDispatch { - hook_callback_id: callback_id, - envelope, - }; - async move { - let started = tokio::time::Instant::now(); - let (response, gate_outcome) = - classify(self.send_hook_run(&dispatch, timeout).await); - xai_grok_telemetry::session_ctx::log_event( - xai_grok_telemetry::events::ClientHookGate { - callback_id: callback_id.to_string(), - tool_name: Some(tool_name.to_string()), - outcome: gate_outcome, - duration_ms: started.elapsed().as_millis() as u64, - }, - ); - (callback_id, response) - } - }) - .collect(); - - while let Some((callback_id, response)) = pending.next().await { + let mut pending = self.client_gate_responses(&groups, Some(tool_name), envelope); + while let Some((callback_id, response, _elapsed)) = pending.next().await { if response.decision == ClientHookDecision::Deny { let reason = response .system_message + .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "blocked by client hook".to_string()); return Ok(Some( self.deny_tool( @@ -286,6 +307,86 @@ impl SessionActor { Ok(None) } + /// Run the client `Stop`/`SubagentStop` gate for a turn-end envelope. + /// Unlike the `PreToolUse` gate (first deny wins), every callback's response + /// is aggregated into a [`StopDispatchResult`] (a `deny` maps to a block). + pub(super) async fn run_stop_client_hooks( + &self, + envelope: &HookEventEnvelope, + ) -> xai_grok_hooks::dispatcher::StopDispatchResult { + use xai_grok_hooks::result::HookRunResult; + + let mut out = xai_grok_hooks::dispatcher::StopDispatchResult::default(); + // Clone: don't hold the borrow across awaits (see run_pre_tool_use_client_hook). + let Some(groups) = self + .client_hooks + .borrow() + .get(&envelope.hook_event_name.canonical()) + .cloned() + else { + return out; + }; + + let match_value = envelope.payload.match_value(); + // Aggregate in registration order so the attributed force-stop winner is + // deterministic (completion order is not). + let mut pending = self.client_gate_responses(&groups, match_value, envelope); + let mut responses = std::collections::HashMap::new(); + while let Some((callback_id, response, elapsed)) = pending.next().await { + responses.insert(callback_id, (response, elapsed)); + } + let ordered = groups + .iter() + .flat_map(|group| group.callback_ids.iter()) + .filter_map(|id| responses.remove(id.as_str()).map(|r| (id.as_str(), r))); + for (callback_id, (response, elapsed)) in ordered { + let hook_name = format!("client:{callback_id}"); + let block_reason = (response.decision == ClientHookDecision::Deny).then(|| { + response + .system_message + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "blocked by client hook".to_string()) + }); + let stop_reason = (response.continue_ == Some(false)).then(|| { + response + .stop_reason + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "stopped by client hook".to_string()) + }); + + let detail = xai_grok_hooks::dispatcher::stop_detail( + stop_reason.is_some(), + stop_reason.as_deref(), + block_reason.as_deref(), + ); + out.results.push(match detail { + Some(detail) => HookRunResult::Blocked { + hook_name: hook_name.clone(), + detail, + elapsed, + http_info: None, + }, + None => HookRunResult::Success { + hook_name: hook_name.clone(), + elapsed, + http_info: None, + }, + }); + + out.absorb( + &hook_name, + xai_grok_hooks::dispatcher::StopSignals { + block_reason, + stop_reason, + additional_context: response + .additional_context + .filter(|c| !c.trim().is_empty()), + }, + ); + } + out + } + /// Issue one `x.ai/hooks/run` reverse request, bounded by a per-callback `timeout`. async fn send_hook_run( &self, @@ -314,8 +415,8 @@ impl SessionActor { let Some(groups) = hooks.get(&envelope.hook_event_name.canonical()) else { return; }; - let tool_name = xai_grok_hooks::dispatcher::extract_tool_name(envelope); - for callback_id in matching_callback_ids(groups, tool_name.as_deref()) { + let match_value = envelope.payload.match_value(); + for callback_id in matching_callback_ids(groups, match_value) { let dispatch = ClientHookDispatch { hook_callback_id: callback_id, envelope, @@ -353,8 +454,7 @@ mod tests { assert_eq!(cont.decision, ClientHookDecision::Continue); assert!(matches!(outcome, ClientHookGateOutcome::Proceeded)); - // An unrecognized decision string fails open (proceeds) but is reported distinctly - // from a normal proceed so client bugs (typo / version skew) stay observable. + // Unknown decision fails open (proceeds) but reports a distinct outcome. let (unknown, outcome) = classify(ReverseOutcome::Responded(raw( serde_json::json!({ "decision": "maybe_later" }), ))); diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs index 8ef538a..510ecc1 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs @@ -21,7 +21,7 @@ pub(super) fn turn_result_to_hook_outcome( /// as its bare snake_case wire string for the `after_turn` hook payload. /// Deliberately `serde_json::to_value` + `as_str`, NOT `to_string` — the /// latter yields the quoted form and fails the workspace decode. -pub(super) fn cancellation_category_wire_string( +pub(super) fn cancellation_category_to_wire_string( category: Option, ) -> Option { let category = category?; @@ -146,6 +146,19 @@ impl SessionActor { HookRunResult::Skipped { hook_name } => { (hook_name.clone(), HookRunStatusDto::Skipped) } + HookRunResult::Blocked { + hook_name, + detail, + elapsed, + .. + } => ( + hook_name.clone(), + HookRunStatusDto::Failed { + error: detail.clone(), + elapsed_ms: elapsed.as_millis() as u64, + blocked: true, + }, + ), HookRunResult::Failed { hook_name, error, @@ -156,6 +169,7 @@ impl SessionActor { HookRunStatusDto::Failed { error: error.clone(), elapsed_ms: elapsed.as_millis() as u64, + blocked: false, }, ), }; @@ -252,6 +266,13 @@ impl SessionActor { elapsed, xai_grok_telemetry::events::HookOutcome::Success, ), + xai_grok_hooks::result::HookRunResult::Blocked { + hook_name, elapsed, .. + } => ( + hook_name, + elapsed, + xai_grok_telemetry::events::HookOutcome::Blocked, + ), xai_grok_hooks::result::HookRunResult::Failed { hook_name, elapsed, .. } => ( @@ -280,8 +301,8 @@ mod notification_hook_filter_tests { }; #[test] - fn hook_execution_does_not_fire_notification_hook() { - let update = XaiSessionUpdate::HookExecution { + fn hook_updates_do_not_fire_notification_hook() { + let execution = XaiSessionUpdate::HookExecution { event_name: "pre_tool_use".into(), tool_name: Some("read_file".into()), prompt_id: None, @@ -291,15 +312,12 @@ mod notification_hook_filter_tests { output: None, }], }; - assert!(notification_hook_for_update(&update).is_none()); - } + assert!(notification_hook_for_update(&execution).is_none()); - #[test] - fn hook_annotation_does_not_fire_notification_hook() { - let update = XaiSessionUpdate::HookAnnotation { + let annotation = XaiSessionUpdate::HookAnnotation { message: "running hooks".into(), }; - assert!(notification_hook_for_update(&update).is_none()); + assert!(notification_hook_for_update(&annotation).is_none()); } #[test] diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs index ea95e81..f9484b9 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs @@ -280,13 +280,7 @@ pub(super) async fn run_session( xai_grok_hooks::dispatcher::dispatch_non_blocking(& registry, xai_grok_hooks::event::HookEventName::SessionEnd, & envelope, & ctx,). await; session.send_hook_execution("session_end", None, None, & results). await; } - let envelope = session.fire_hook(xai_grok_hooks::event::HookEventName::Stop, - None, xai_grok_hooks::event::HookPayload::Stop { reason : "channel_closed" - .to_string(), },); if let Some(registry) = session.hook_registry.borrow() - .clone() { let ctx = session.hook_run_ctx(); let results = - xai_grok_hooks::dispatcher::dispatch_non_blocking(& registry, - xai_grok_hooks::event::HookEventName::Stop, & envelope, & ctx,). await; - session.send_hook_execution("stop", None, None, & results). await; } let mut + session.dispatch_session_end_stop("channel_closed"). await; let mut session_end_result = "disabled"; let mut total_chunks_at_end = 0usize; if ! session.startup_hints.is_subagent { if let Some(storage) = session.memory .storage() { let conversation = session.chat_state_handle.get_conversation(). @@ -839,13 +833,7 @@ pub(super) async fn run_session( xai_grok_hooks::dispatcher::dispatch_non_blocking(& registry, xai_grok_hooks::event::HookEventName::SessionEnd, & envelope, & ctx,). await; session.send_hook_execution("session_end", None, None, & results). await; } - let envelope = session.fire_hook(xai_grok_hooks::event::HookEventName::Stop, - None, xai_grok_hooks::event::HookPayload::Stop { reason : "shutdown" - .to_string(), },); if let Some(registry) = session.hook_registry.borrow() - .clone() { let ctx = session.hook_run_ctx(); let results = - xai_grok_hooks::dispatcher::dispatch_non_blocking(& registry, - xai_grok_hooks::event::HookEventName::Stop, & envelope, & ctx,). await; - session.send_hook_execution("stop", None, None, & results). await; } let mut + session.dispatch_session_end_stop("shutdown"). await; let mut session_end_result = "disabled"; let mut total_chunks_at_end = 0usize; if ! session.startup_hints.is_subagent { if let Some(storage) = session.memory .storage() { let conversation = session.chat_state_handle.get_conversation(). diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs index 8619347..c12365b 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs @@ -870,6 +870,11 @@ pub(crate) async fn spawn_session_actor( session_id_str: session_info.id.0.to_string(), respect_gitignore, path_not_found_hints, + scheduler_background_loops: crate::util::config::resolve_scheduler_background_loops( + remote_settings + .as_ref() + .and_then(|r| r.scheduler_background_loops), + ), mcp_state: mcp_state.clone(), managed_gateway_tool_client: managed_gateway_tool_client.clone(), is_non_interactive: startup_hints.non_interactive, @@ -1344,7 +1349,6 @@ pub(crate) async fn spawn_session_actor( rebuild_spec: rebuild_spec.clone(), image_description_model, image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: workspace_ops.clone(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs new file mode 100644 index 0000000..c15b23c --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs @@ -0,0 +1,497 @@ +//! The turn-end `Stop`/`SubagentStop` gate for `SessionActor`. + +use super::*; +use xai_grok_hooks::event::{ + self, BackgroundTaskType, StopBackgroundTask, StopSessionCron, clip_stop_entry_text, +}; +use xai_grok_hooks::{dispatcher, result}; + +pub const MAX_STOP_HOOK_CONTINUATIONS_PER_TURN: u32 = 8; + +const SESSION_END_STOP_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + +/// `command` is a shell-only field, so a monitor's watch command is carried in +/// `description` instead. +fn stop_entry_from_task(task: &xai_grok_tools::types::TaskSnapshot) -> StopBackgroundTask { + let command_text = + clip_stop_entry_text(task.display_command.as_deref().unwrap_or(&task.command)); + let (kind, command, description) = match task.kind { + xai_grok_tools::computer::types::TaskKind::Bash => { + (BackgroundTaskType::Shell, Some(command_text), None) + } + xai_grok_tools::computer::types::TaskKind::Monitor => { + (BackgroundTaskType::Monitor, None, Some(command_text)) + } + }; + StopBackgroundTask { + id: task.task_id.clone(), + r#type: kind, + status: "running".to_string(), + description, + command, + agent_type: None, + } +} + +fn stop_entry_from_subagent( + summary: &xai_grok_tools::implementations::grok_build::task::types::ActiveSubagentSummary, +) -> StopBackgroundTask { + StopBackgroundTask { + id: summary.subagent_id.clone(), + r#type: BackgroundTaskType::Subagent, + status: "running".to_string(), + description: Some(clip_stop_entry_text(&summary.description)), + command: None, + agent_type: Some(summary.subagent_type.clone()), + } +} + +fn stop_cron_from_scheduled( + task: &xai_grok_tools::implementations::grok_build::scheduler::types::ScheduledTask, +) -> StopSessionCron { + StopSessionCron { + id: task.id.clone(), + schedule: + xai_grok_tools::implementations::grok_build::scheduler::interval::interval_to_human( + task.interval_secs, + ), + recurring: task.recurring, + prompt: clip_stop_entry_text(&task.prompt), + } +} + +const STOP_FEEDBACK_TEXT_MAX: usize = 10_000; + +fn format_stop_feedback(blocks: &[dispatcher::StopBlock], additional_context: &[String]) -> String { + use std::fmt::Write as _; + let clip = |text: &str| event::clip_text(text, STOP_FEEDBACK_TEXT_MAX); + let mut feedback = String::new(); + if !blocks.is_empty() { + feedback.push_str("Stop hook feedback:\n"); + for block in blocks { + let _ = writeln!(feedback, "- {}", clip(&block.reason)); + } + } + for context in additional_context { + if !feedback.is_empty() { + feedback.push('\n'); + } + feedback.push_str(&clip(context)); + } + feedback +} + +/// Downgrade `Blocked` to `Success` for the observe-only session-end fire: the +/// decision is discarded, so scrollback and telemetry must not report a block. +pub(super) fn demote_ignored_blocks( + results: Vec, +) -> Vec { + use xai_grok_hooks::result::HookRunResult; + results + .into_iter() + .map(|result| match result { + HookRunResult::Blocked { + hook_name, + elapsed, + http_info, + .. + } => HookRunResult::Success { + hook_name, + elapsed, + http_info, + }, + other => other, + }) + .collect() +} + +impl SessionActor { + /// Dispatch the observe-only session-end `Stop`: runs in stop-gate mode so + /// exit code 2 parses as a block, but the decision is discarded (no turn + /// left to continue). + pub(crate) async fn dispatch_session_end_stop(&self, reason: &str) { + if self.startup_hints.is_subagent || !self.hook_event_active(event::HookEventName::Stop) { + return; + } + let envelope = self.fire_hook( + event::HookEventName::Stop, + None, + event::HookPayload::Stop { + reason: reason.to_string(), + stop_hook_active: false, + last_assistant_message: None, + background_tasks: None, + session_crons: None, + }, + ); + let Some(registry) = self.hook_registry.borrow().clone() else { + return; + }; + let ctx = self.hook_run_ctx(); + let dispatch = + dispatcher::dispatch_stop(®istry, event::HookEventName::Stop, &envelope, &ctx); + let Ok(mut result) = tokio::time::timeout(SESSION_END_STOP_BUDGET, dispatch).await else { + tracing::warn!("session-end stop hooks exceeded the shutdown budget; skipping"); + return; + }; + result.results = demote_ignored_blocks(result.results); + self.send_hook_execution("stop", None, None, &result.results) + .await; + self.emit_hook_executed_telemetry("stop", None, &result.results) + .await; + } + + pub(crate) async fn list_active_subagents( + &self, + ) -> Vec { + use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentEvent, SubagentListActiveRequest, + }; + let Some(ref event_tx) = self.tool_context.subagent_event_tx else { + return Vec::new(); + }; + let (tx, rx) = tokio::sync::oneshot::channel(); + if event_tx + .send(SubagentEvent::ListActive(SubagentListActiveRequest { + parent_session_id: self.session_id_string(), + respond_to: tx, + })) + .is_err() + { + return Vec::new(); + } + rx.await.unwrap_or_default() + } + + /// Snapshot in-flight background work and scheduled wakeups for the Stop + /// hook input (filtering out tasks owned by other sessions on the shared + /// backend). + async fn stop_gate_work_snapshot(&self) -> (Vec, Vec) { + let bridge = self.tool_bridge_handle(); + let my_session = self.session_id_string(); + let mut tasks: Vec = bridge + .list_background_tasks() + .await + .iter() + .filter(|t| t.is_outstanding()) + .filter(|t| { + t.owner_session_id + .as_deref() + .is_none_or(|owner| owner == my_session) + }) + .map(stop_entry_from_task) + .collect(); + tasks.extend( + self.list_active_subagents() + .await + .iter() + .map(stop_entry_from_subagent), + ); + + let now = chrono::Utc::now(); + let crons = bridge + .list_scheduled_tasks() + .await + .iter() + .filter(|t| !t.is_expired(now)) + .map(stop_cron_from_scheduled) + .collect(); + (tasks, crons) + } + + async fn announce_force_stop(&self, prevent: &dispatcher::StopBlock) { + self.send_hook_annotation(&format!( + "\u{26a0} Hook `{}` stopped the agent: {}", + prevent.hook_name, prevent.reason + )) + .await; + } + + async fn build_stop_payload(&self, stop_hook_active: bool) -> event::HookPayload { + let last_assistant_message = self + .chat_state_handle + .get_last_assistant_text_in_turn() + .await; + if self.startup_hints.is_subagent { + event::HookPayload::SubagentStop { + phase: event::SubagentStopPhase::Gate, + subagent_id: self.session_id_string(), + subagent_type: self.subagent_type_label().unwrap_or_default(), + stop_hook_active: Some(stop_hook_active), + last_assistant_message, + } + } else { + let (background_tasks, session_crons) = self.stop_gate_work_snapshot().await; + event::HookPayload::Stop { + reason: "end_turn".to_string(), + stop_hook_active, + last_assistant_message, + background_tasks: Some(background_tasks), + session_crons: Some(session_crons), + } + } + } + + async fn emit_stop_results( + &self, + event: event::HookEventName, + prompt_id: &str, + results: &[result::HookRunResult], + ) { + let name = event.to_string(); + self.send_hook_execution(&name, None, Some(prompt_id), results) + .await; + self.emit_hook_executed_telemetry(&name, None, results) + .await; + } + + /// Run the turn-end `Stop`/`SubagentStop` hook gate and decide whether the + /// agent may stop or must keep working. Hook failures fail open (the agent + /// stops normally). + pub(super) async fn run_stop_gate( + &self, + prompt_id: &str, + continuations_this_turn: u32, + ) -> StopGateDecision { + let event = if self.startup_hints.is_subagent { + event::HookEventName::SubagentStop + } else { + event::HookEventName::Stop + }; + let has_file_hooks = self + .hook_registry + .borrow() + .as_ref() + .is_some_and(|r| r.has_enabled_hooks_for_canonical(event)); + let has_client_hooks = self.client_hooks.borrow().contains_key(&event); + if !has_file_hooks && !has_client_hooks { + return StopGateDecision::AllowStop; + } + // At the cap no hook is consulted or notified for this forced stop, + // unlike the force-stop path below which still notifies observers. + if continuations_this_turn >= MAX_STOP_HOOK_CONTINUATIONS_PER_TURN { + tracing::warn!( + continuations_this_turn, + "stop hook continuation limit reached; ending the turn" + ); + self.send_hook_annotation(&format!( + "\u{26a0} Stop hooks kept the agent working {MAX_STOP_HOOK_CONTINUATIONS_PER_TURN} times this turn: limit reached, ending the turn" + )) + .await; + return StopGateDecision::AllowStop; + } + + let payload = self.build_stop_payload(continuations_this_turn > 0).await; + // Gate envelope via `make_hook_envelope`, not the observe-notify + // `fire_hook`: client hooks get the awaited `x.ai/hooks/run` request + // below, not a fire-and-forget event. + let envelope = self.make_hook_envelope(event, Some(prompt_id.to_string()), payload); + + let mut result = dispatcher::StopDispatchResult::default(); + // Clone out of the RefCell before the awaits so no `Ref` is held + // across them. + let registry = self.hook_registry.borrow().clone(); + if let Some(registry) = registry { + let ctx = self.hook_run_ctx(); + result = dispatcher::dispatch_stop(®istry, event, &envelope, &ctx).await; + } + + if let Some(prevent) = result.prevent_continuation.take() { + // Force-stop: skip the client gate (its signals would be discarded) + // but still send the observe notification so client callbacks see + // the turn end. + self.emit_stop_results(event, prompt_id, &result.results) + .await; + self.notify_client_hooks(&envelope); + self.announce_force_stop(&prevent).await; + return StopGateDecision::AllowStop; + } + + // Merge file and client results and emit once: one stop gate is one + // scrollback entry and one telemetry batch. + let client = self.run_stop_client_hooks(&envelope).await; + let mut all_results = std::mem::take(&mut result.results); + all_results.extend(client.results); + if !all_results.is_empty() { + self.emit_stop_results(event, prompt_id, &all_results).await; + } + + result.blocks.extend(client.blocks); + result.additional_context.extend(client.additional_context); + if let Some(prevent) = client.prevent_continuation { + self.announce_force_stop(&prevent).await; + return StopGateDecision::AllowStop; + } + + if !result.wants_continuation() { + return StopGateDecision::AllowStop; + } + + self.announce_keep_working(&result.blocks, &result.additional_context) + .await; + StopGateDecision::KeepWorking { + feedback: format_stop_feedback(&result.blocks, &result.additional_context), + } + } + + /// Annotate the scrollback when a stop gate keeps the agent working: one line + /// per block (with `HookBlocked` telemetry), or the context lines when only + /// `additionalContext` was returned. + async fn announce_keep_working( + &self, + blocks: &[dispatcher::StopBlock], + additional_context: &[String], + ) { + for block in blocks { + self.send_hook_annotation(&format!( + "\u{21a9} Stop blocked by hook `{}`, continuing: {}", + block.hook_name, block.reason + )) + .await; + xai_grok_telemetry::session_ctx::log_event(xai_grok_telemetry::events::HookBlocked { + hook_name: block.hook_name.clone(), + }); + } + if blocks.is_empty() { + for context in additional_context { + self.send_hook_annotation(&format!( + "\u{21a9} Stop hook feedback, continuing: {context}" + )) + .await; + } + } + } +} + +#[cfg(test)] +mod stop_gate_snapshot_tests { + use super::*; + + fn task_snapshot( + kind: xai_grok_tools::computer::types::TaskKind, + ) -> xai_grok_tools::types::TaskSnapshot { + xai_grok_tools::types::TaskSnapshot { + task_id: "task-1".into(), + command: "sandbox-exec tail -f /var/log/syslog".into(), + display_command: Some("tail -f /var/log/syslog".into()), + cwd: "/tmp".into(), + start_time: std::time::SystemTime::UNIX_EPOCH, + end_time: None, + output: String::new(), + output_file: std::path::PathBuf::from("/tmp/out"), + truncated: false, + exit_code: None, + signal: None, + completed: false, + kind, + block_waited: false, + explicitly_killed: false, + owner_session_id: None, + } + } + + #[test] + fn task_snapshot_maps_to_stop_entry() { + let shell = stop_entry_from_task(&task_snapshot( + xai_grok_tools::computer::types::TaskKind::Bash, + )); + assert_eq!(shell.r#type, BackgroundTaskType::Shell); + assert_eq!(shell.command.as_deref(), Some("tail -f /var/log/syslog")); + assert!(shell.description.is_none()); + assert_eq!(shell.status, "running"); + assert!(shell.agent_type.is_none()); + + let monitor = stop_entry_from_task(&task_snapshot( + xai_grok_tools::computer::types::TaskKind::Monitor, + )); + assert_eq!(monitor.r#type, BackgroundTaskType::Monitor); + assert!(monitor.command.is_none()); + assert_eq!( + monitor.description.as_deref(), + Some("tail -f /var/log/syslog") + ); + } + + #[test] + fn subagent_summary_maps_to_stop_entry() { + let summary = + xai_grok_tools::implementations::grok_build::task::types::ActiveSubagentSummary { + subagent_id: "sub-1".into(), + subagent_type: "explore".into(), + description: "d".repeat(2000), + elapsed_ms: 5, + }; + let entry = stop_entry_from_subagent(&summary); + assert_eq!(entry.r#type, BackgroundTaskType::Subagent); + assert_eq!(entry.agent_type.as_deref(), Some("explore")); + let description = entry.description.unwrap(); + assert!(description.ends_with("… [+1000 chars]")); + assert!(entry.command.is_none()); + } + + #[test] + fn format_stop_feedback_lists_blocks_then_appends_context() { + let block = |reason: &str| dispatcher::StopBlock { + hook_name: "h".into(), + reason: reason.into(), + }; + assert_eq!( + format_stop_feedback(&[block("first"), block("second")], &[]), + "Stop hook feedback:\n- first\n- second\n" + ); + assert_eq!( + format_stop_feedback(&[block("fix tests")], &["note".to_string()]), + "Stop hook feedback:\n- fix tests\n\nnote" + ); + assert_eq!( + format_stop_feedback(&[], &["only context".to_string()]), + "only context" + ); + } + + #[test] + fn scheduled_task_maps_to_stop_cron() { + let task = + xai_grok_tools::implementations::grok_build::scheduler::types::ScheduledTask::new( + 300, + "check the build".into(), + true, + false, + ); + let cron = stop_cron_from_scheduled(&task); + assert_eq!(cron.schedule, "every 5 minutes"); + assert!(cron.recurring); + assert_eq!(cron.prompt, "check the build"); + } + + #[test] + fn demote_ignored_blocks_downgrades_only_blocked() { + use xai_grok_hooks::result::HookRunResult; + + let results = demote_ignored_blocks(vec![ + HookRunResult::Blocked { + hook_name: "gate".into(), + detail: "blocked stop: run the tests".into(), + elapsed: std::time::Duration::from_millis(5), + http_info: None, + }, + HookRunResult::Failed { + hook_name: "broken".into(), + error: "exit code 1".into(), + elapsed: std::time::Duration::from_millis(3), + http_info: None, + }, + HookRunResult::Skipped { + hook_name: "disabled".into(), + }, + ]); + + assert!( + matches!(&results[0], HookRunResult::Success { hook_name, .. } if hook_name == "gate"), + "a discarded decision must read as success, got {:?}", + results[0] + ); + assert!(matches!(&results[1], HookRunResult::Failed { .. })); + assert!(matches!(&results[2], HookRunResult::Skipped { .. })); + } +} diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs index 6d589f9..73a88b8 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs @@ -926,7 +926,6 @@ impl SessionActor { tool_use_id: call.id.clone(), tool_input: hook_tool_input, tool_input_truncated: hook_tool_input_truncated, - permission_mode: Some(self.permission_mode_label().to_string()), subagent_type: self.subagent_type_label(), }, ); @@ -1157,7 +1156,11 @@ impl SessionActor { match decision { Decision::PolicyDeny(ref reason) | Decision::Reject(ref reason) => { let is_policy_deny = matches!(&decision, Decision::PolicyDeny(_)); - let message = format!("{reason} for tool `{}`", call.function.name); + let message = if is_policy_deny { + format!("Tool `{}` was not executed: {reason}", call.function.name) + } else { + format!("{reason} for tool `{}`", call.function.name) + }; self.handle_tool_not_executed(&call.id, &tool_call_id, message) .await?; let (tool_input_value, tool_input_truncated) = @@ -1810,12 +1813,19 @@ impl SessionActor { vec![], vec![], ), - ToolInput::SchedulerCreate(ref sc) => ( - format!("Create scheduled task (every {})", sc.interval), - acp::ToolKind::Other, - vec![], - vec![], - ), + ToolInput::SchedulerCreate(ref sc) => { + let title = match (&sc.task_id, &sc.interval) { + (Some(id), Some(interval)) => { + format!("Update scheduled task {id} (every {interval})") + } + (Some(id), None) => format!("Update scheduled task {id}"), + (None, Some(interval)) => { + format!("Create scheduled task (every {interval})") + } + (None, None) => "Create scheduled task".to_string(), + }; + (title, acp::ToolKind::Other, vec![], vec![]) + } ToolInput::SchedulerDelete(ref sd) => ( format!("Delete scheduled task: {}", sd.id), acp::ToolKind::Other, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs index a8e31a4..a8bc8cf 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs @@ -774,6 +774,7 @@ impl SessionActor { let result = { let mut round_trace = trace_gcs_config; let mut round_artifact = artifact_tracker; + let mut stop_continuations_this_turn: u32 = 0; loop { if self.goal_harness_enabled() { let goal_loop_active = self.goal_tracker.lock().status() @@ -791,21 +792,35 @@ impl SessionActor { if !matches!(round, Ok(TurnOutcome::Completed { .. })) { break round; } - if matches!(round, Ok(TurnOutcome::Completed { refusal: true, .. })) { + if matches!( + round, + Ok(TurnOutcome::Completed { + refusal: Some(_), + .. + }) + ) { break round; } let goal_active = laziness_injection_active( self.goal_harness_enabled(), self.goal_tracker.lock().status(), ); - if !goal_active { - break round; + if goal_active + && let GoalRoundDecision::Continue(directive) = self.run_goal_round_end().await + { + self.inject_goal_continuation_message(directive).await; + continue; } - match self.run_goal_round_end().await { - GoalRoundDecision::Continue(directive) => { - self.inject_goal_continuation_message(directive).await; + match self + .run_stop_gate(prompt_id, stop_continuations_this_turn) + .await + { + StopGateDecision::AllowStop => break round, + StopGateDecision::KeepWorking { feedback } => { + stop_continuations_this_turn += 1; + self.chat_state_handle + .push_user_message(ConversationItem::stop_hook_feedback(feedback)); } - GoalRoundDecision::EndTurn => break round, } } }; @@ -833,12 +848,26 @@ impl SessionActor { }) .await; match &result { - Ok(TurnOutcome::Completed { .. }) => { + Ok(TurnOutcome::Completed { refusal, .. }) => { self.emit_turn_ended( crate::session::events::TurnOutcomeLabel::Completed, None, None, ); + if let Some(explanation) = refusal { + let details = (!explanation.is_empty()).then(|| explanation.clone()); + self.dispatch_hook( + xai_grok_hooks::event::HookEventName::StopFailure, + xai_grok_hooks::event::HookPayload::StopFailure { + error: xai_grok_hooks::event::StopFailureKind::InvalidRequest, + error_details: details.clone(), + last_assistant_message: details, + }, + Some(prompt_id), + None, + ) + .await; + } self.send_after_turn_event(xai_tool_protocol::turn_hook::AfterTurnPayload { turn_number: current_prompt_index as u64, outcome: xai_tool_protocol::turn_hook::TurnHookOutcome::Completed, @@ -877,7 +906,7 @@ impl SessionActor { tool_call_count: turn_tool_count, model_id: turn_model_id.clone(), written_repo_paths: Vec::new(), - cancellation_category: cancellation_category_wire_string(*category), + cancellation_category: cancellation_category_to_wire_string(*category), cancellation_context: context.clone(), }) .await; @@ -960,7 +989,9 @@ impl SessionActor { self.dispatch_hook( xai_grok_hooks::event::HookEventName::StopFailure, xai_grok_hooks::event::HookPayload::StopFailure { - error: format!("{err}"), + error: Self::stop_failure_error_type(err), + error_details: Self::turn_error_detail(err), + last_assistant_message: Some(Self::format_turn_error_message(err)), }, Some(prompt_id), None, @@ -987,22 +1018,6 @@ impl SessionActor { }, ); } - let stop_reason_str = match &result { - Ok(TurnOutcome::Completed { .. }) => "end_turn", - Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => { - "cancelled" - } - Err(_) => "error", - }; - self.dispatch_hook( - xai_grok_hooks::event::HookEventName::Stop, - xai_grok_hooks::event::HookPayload::Stop { - reason: stop_reason_str.to_string(), - }, - Some(prompt_id), - None, - ) - .await; match &result { Ok(TurnOutcome::Completed { .. }) => { for contributor in self.extension_registry.turn_lifecycle_contributors() { @@ -1062,7 +1077,7 @@ impl SessionActor { refusal, .. } => ( - if refusal { + if refusal.is_some() { acp::StopReason::Refusal } else { acp::StopReason::EndTurn @@ -2210,7 +2225,7 @@ impl SessionActor { snapshot: Box::new(snapshot), tools_called: turn_tools_called, structured_output, - refusal: turn_refused, + refusal: turn_refused.then(|| refusal_explanation.clone().unwrap_or_default()), }); } if structured_output_tool && let Some(validator) = structured_output_validator.as_ref() @@ -2237,7 +2252,7 @@ impl SessionActor { snapshot: Box::new(snapshot), tools_called: turn_tools_called, structured_output: Some(validated), - refusal: false, + refusal: None, }); } StructuredOutputStep::Retry => continue, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs index 903d0f3..9a0c915 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs @@ -339,17 +339,54 @@ impl SessionActor { .await; } + /// Telemetry error category; delegates to `stop_failure_error_type` so the + /// two classifications cannot drift. pub(super) fn classify_turn_error(err: &acp::Error) -> String { - match i32::from(err.code) { - crate::sampling::error::RATE_LIMITED_ERROR_CODE => "rate_limit", - -32000 => "auth", - -32600 => "invalid_request", - -32603 => "internal", - _ => "unknown", + use xai_grok_hooks::event::StopFailureKind as K; + match Self::stop_failure_error_type(err) { + K::RateLimit => "rate_limit", + K::AuthenticationFailed => "auth", + K::InvalidRequest => "invalid_request", + K::ServerError => "internal", + K::MaxOutputTokens => "max_tokens", + K::Unknown => "unknown", } .to_string() } + /// The `StopFailure` hook input's classified `error`. Structured markers win + /// over the JSON-RPC code because they are more specific; anything the + /// runtime cannot distinguish stays `Unknown`. + pub(super) fn stop_failure_error_type( + err: &acp::Error, + ) -> xai_grok_hooks::event::StopFailureKind { + use xai_grok_hooks::event::StopFailureKind as K; + if crate::sampling::error::stop_reason_for_turn_error(err) == "MaxTokens" { + return K::MaxOutputTokens; + } + // The data-carried HTTP status discriminates over the JSON-RPC code. 403 + // is content-safety, not auth: it folds into `invalid_request` on the turn + // path (carries `http_status: 403`) and `server_error` on the setup path + // (no status, so `-32603` below). + match crate::sampling::error::http_status_from_error(err) { + Some(401) => return K::AuthenticationFailed, + Some(429) | Some(503) | Some(529) => return K::RateLimit, + Some(s) if (400..500).contains(&s) => return K::InvalidRequest, + Some(s) if s >= 500 => return K::ServerError, + _ => {} + } + match i32::from(err.code) { + crate::sampling::error::RATE_LIMITED_ERROR_CODE => K::RateLimit, + -32000 => K::AuthenticationFailed, + -32002 | -32600 | -32602 => K::InvalidRequest, + -32603 => K::ServerError, + _ => K::Unknown, + } + } + + /// Whether a turn error is transient infra worth a goal retry. Keys on the + /// JSON-RPC code only (unlike `stop_failure_error_type`), so `-32603` counts + /// as infra. pub(super) fn is_infra_turn_error(err: &acp::Error) -> bool { matches!( i32::from(err.code), @@ -400,7 +437,7 @@ impl SessionActor { } /// Extract the best human-readable detail from an infra turn error. - fn turn_error_detail(err: &acp::Error) -> Option { + pub(super) fn turn_error_detail(err: &acp::Error) -> Option { err.data .as_ref() .and_then(crate::sampling::error::error_detail_from_data) diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/types.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/types.rs index b654401..50702e8 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/types.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/types.rs @@ -50,9 +50,9 @@ pub(crate) enum TurnOutcome { snapshot: Box>, tools_called: Vec, structured_output: Option>, - /// Terminal response was a content-filter refusal; maps the prompt's - /// ACP stop reason to `Refusal` instead of `EndTurn`. - refusal: bool, + /// `Some(explanation)` marks a content-filter refusal (empty when the + /// provider gave no message). + refusal: Option, }, /// The turn was cancelled (user rejection, hook denial, doom loop, etc.). /// The category distinguishes the cause for analytics. @@ -223,6 +223,14 @@ pub(crate) enum GoalRoundDecision { EndTurn, } +/// Decision from the turn-end stop gate: allow the turn to end, or keep the +/// agent working by injecting `feedback` as a synthetic user message. +#[derive(Debug)] +pub(crate) enum StopGateDecision { + AllowStop, + KeepWorking { feedback: String }, +} + /// Which part of the model's streaming lifecycle the capture was tied to /// when it was last touched — i.e. what the model was doing at the moment /// the turn was cut off. Serialized onto `streaming_partial.json` so trace diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/updates.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/updates.rs index da0b812..a9f44c3 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/updates.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/updates.rs @@ -2,15 +2,6 @@ //! its buffered/transient/direct variants, xAI-notification handling, and //! the gateway-bridge dispatch shims. use super::*; -/// Exit code reported on the `SubagentStop` hook payload; unknown statuses report none. -fn subagent_exit_code(status: &str) -> Option { - match status { - "completed" => Some(0), - "failed" => Some(1), - "cancelled" => Some(-1), - _ => None, - } -} /// Result of applying a subagent fold into parent ledgers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SubagentUsageApply { @@ -428,13 +419,6 @@ impl SessionActor { model, .. } => { - self.subagent_spawn_info.lock().insert( - subagent_id.clone(), - SubagentSpawnInfo { - description: description.clone(), - subagent_type: subagent_type.clone(), - }, - ); if let Some(parent_id) = resumed_from { debug_assert_ne!(parent_id, subagent_id, "subagent cannot resume itself"); } @@ -508,38 +492,9 @@ impl SessionActor { } XaiSessionUpdate::SubagentFinished { subagent_id, - status, - duration_ms, tokens_used, .. } => { - let spawn_info = self.subagent_spawn_info.lock().remove(subagent_id); - let exit_code = subagent_exit_code(status.as_str()); - let envelope = self.fire_hook( - xai_grok_hooks::event::HookEventName::SubagentEnd, - None, - xai_grok_hooks::event::HookPayload::SubagentStop { - subagent_id: subagent_id.clone(), - subagent_type: spawn_info - .as_ref() - .map(|i| i.subagent_type.clone()) - .unwrap_or_default(), - description: spawn_info.map(|i| i.description), - exit_code, - duration_ms: Some(*duration_ms), - }, - ); - let hook_registry_snapshot = self.hook_registry.borrow().clone(); - if let Some(registry) = hook_registry_snapshot { - let ctx = self.hook_run_ctx(); - let _ = xai_grok_hooks::dispatcher::dispatch_non_blocking( - ®istry, - xai_grok_hooks::event::HookEventName::SubagentEnd, - &envelope, - &ctx, - ) - .await; - } { let mut records = self.subagent_token_records.lock(); if let Some(rec) = records.get_mut(subagent_id) { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index a9d0676..296e396 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -280,7 +280,6 @@ async fn persist_ack_waits_for_disk_flush_before_success() { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), @@ -736,7 +735,6 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), @@ -1026,7 +1024,6 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), @@ -2259,7 +2256,6 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs index 0b16e0a..8c755bc 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs @@ -1,7 +1,52 @@ use super::support::*; use super::*; -/// Client hooks must fire even with no on-disk hook registry: `notify_client_hooks` +fn install_client_hook( + actor: &SessionActor, + event: xai_grok_hooks::event::HookEventName, + callback_ids: &[&str], +) { + let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); + client_hooks.insert( + event, + vec![crate::extensions::hooks::ClientHookGroup { + matcher: None, + callback_ids: callback_ids.iter().map(|s| s.to_string()).collect(), + timeout: None, + }], + ); + *actor.client_hooks.borrow_mut() = client_hooks; +} + +/// Acks UI notifications so `deny_tool` cannot block the gate. +fn spawn_deny_responder( + gateway_rx: tokio::sync::mpsc::UnboundedReceiver, + reason: &'static str, +) { + let mut gateway_rx = gateway_rx; + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + match msg { + xai_acp_lib::AcpClientMessage::ExtMethod(args) => { + let deny: Arc = + serde_json::value::to_raw_value(&serde_json::json!({ + "decision": "deny", + "systemMessage": reason, + })) + .unwrap() + .into(); + let _ = args.response_tx.send(Ok(acp::ExtResponse::new(deny))); + } + xai_acp_lib::AcpClientMessage::SessionNotification(args) => { + let _ = args.response_tx.send(Ok(())); + } + _ => {} + } + } + }); +} + +/// Client hooks fire even with no on-disk hook registry: `notify_client_hooks` /// reads `client_hooks` (never `hook_registry`) and its call sites sit outside the /// file-registry guard. #[tokio::test(flavor = "current_thread")] @@ -19,22 +64,21 @@ async fn client_hooks_fire_without_file_registry() { actor.hook_registry.borrow().is_none(), "fixture must have no file registry for this invariant" ); - let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); - client_hooks.insert( + install_client_hook( + &actor, xai_grok_hooks::event::HookEventName::Stop, - vec![crate::extensions::hooks::ClientHookGroup { - matcher: None, - callback_ids: vec!["cb_0".to_string()], - timeout: None, - }], + &["cb_0"], ); - *actor.client_hooks.borrow_mut() = client_hooks; actor.fire_hook( xai_grok_hooks::event::HookEventName::Stop, None, xai_grok_hooks::event::HookPayload::Stop { reason: "end_turn".to_string(), + stop_hook_active: false, + last_assistant_message: None, + background_tasks: None, + session_crons: None, }, ); @@ -53,103 +97,16 @@ async fn client_hooks_fire_without_file_registry() { .await; } -/// The PreToolUse gate blocks a tool when a client hook returns `deny`: the reverse -/// `x.ai/hooks/run` request is answered with a deny and `run_pre_tool_use_client_hook` -/// returns `ToolLoop::HookDenied`. Complements the pure `classify` test by covering the -/// gate wiring (the one new path that can block tool execution). -#[tokio::test(flavor = "current_thread")] -async fn pre_tool_use_client_deny_blocks_the_tool() { - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let (gateway_tx, mut gateway_rx) = - tokio::sync::mpsc::unbounded_channel::(); - let (persistence_tx, _persistence_rx) = - tokio::sync::mpsc::unbounded_channel::(); - let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; - - let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); - client_hooks.insert( - xai_grok_hooks::event::HookEventName::PreToolUse, - vec![crate::extensions::hooks::ClientHookGroup { - matcher: None, - callback_ids: vec!["cb_0".to_string()], - timeout: None, - }], - ); - *actor.client_hooks.borrow_mut() = client_hooks; - - // Answer the x.ai/hooks/run reverse request with a deny; ack the UI - // notifications `deny_tool` emits so it can't block the gate. - tokio::task::spawn_local(async move { - while let Some(msg) = gateway_rx.recv().await { - match msg { - xai_acp_lib::AcpClientMessage::ExtMethod(args) => { - let deny: Arc = - serde_json::value::to_raw_value(&serde_json::json!({ - "decision": "deny", - "systemMessage": "nope", - })) - .unwrap() - .into(); - let _ = args.response_tx.send(Ok(acp::ExtResponse::new(deny))); - } - xai_acp_lib::AcpClientMessage::SessionNotification(args) => { - let _ = args.response_tx.send(Ok(())); - } - _ => {} - } - } - }); - - let call = ToolCallResponse { - id: "call_1".to_string(), - kind: "function".to_string(), - function: crate::sampling::types::ToolCallFunction::new( - "run_terminal_command", - "{}", - ), - }; - let tool_call_id = acp::ToolCallId::new("call_1"); - let envelope = actor.make_hook_envelope( - xai_grok_hooks::event::HookEventName::PreToolUse, - None, - xai_grok_hooks::event::HookPayload::PreToolUse { - tool_name: call.function.name.clone(), - tool_use_id: call.id.clone(), - tool_input: serde_json::json!({}), - tool_input_truncated: false, - permission_mode: None, - subagent_type: None, - }, - ); - - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - actor.run_pre_tool_use_client_hook(&call, &tool_call_id, &envelope), - ) - .await - .expect("the gate must not hang") - .expect("the gate must not error"); - assert!( - matches!(result, Some(ToolLoop::HookDenied { .. })), - "a client deny must block the tool" - ); - }) - .await; -} - /// A `use_tool` call whose wire `function.name` is the dispatcher surfaces to PreToolUse /// hooks as its resolved target, so a matcher keyed on the qualified MCP name -/// (`linear__save_issue`) gates the dispatch. Drives the real `prepare_tool_call` -/// construction path (not a hand-built envelope); the deny only fires if the resolved -/// name reached the envelope. +/// (`linear__save_issue`) gates the dispatch. Drives the real `prepare_tool_call` path; +/// the deny fires only if the resolved name reached the envelope. #[tokio::test(flavor = "current_thread")] async fn pre_tool_use_resolves_meta_dispatch_tool_name_end_to_end() { let local = tokio::task::LocalSet::new(); local .run_until(async { - let (gateway_tx, mut gateway_rx) = + let (gateway_tx, gateway_rx) = tokio::sync::mpsc::unbounded_channel::(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -174,29 +131,8 @@ async fn pre_tool_use_resolves_meta_dispatch_tool_name_end_to_end() { }], ); *actor.client_hooks.borrow_mut() = client_hooks; + spawn_deny_responder(gateway_rx, "nope"); - tokio::task::spawn_local(async move { - while let Some(msg) = gateway_rx.recv().await { - match msg { - xai_acp_lib::AcpClientMessage::ExtMethod(args) => { - let deny: Arc = - serde_json::value::to_raw_value(&serde_json::json!({ - "decision": "deny", - "systemMessage": "nope", - })) - .unwrap() - .into(); - let _ = args.response_tx.send(Ok(acp::ExtResponse::new(deny))); - } - xai_acp_lib::AcpClientMessage::SessionNotification(args) => { - let _ = args.response_tx.send(Ok(())); - } - _ => {} - } - } - }); - - // Wire `function.name` is the dispatcher; the arguments carry the real target. let call = ToolCallResponse { id: "call_1".to_string(), kind: "function".to_string(), @@ -223,13 +159,9 @@ async fn pre_tool_use_resolves_meta_dispatch_tool_name_end_to_end() { .await; } -/// Subagent inheritance (the design headline): a tool call inside a SUBAGENT is gated by -/// the PARENT's registered client hook. In prod the subagent inherits the parent's hooks via -/// `ctx.client_hooks.clone()` (`agent/subagent/`), itself fed by the `SnapshotClientHooks` -/// clone (`session.client_hooks.clone()`). This is the seam-level test: it reproduces that -/// exact clone into a child `SessionActor` (a full subagent spawn needs the sampler / child -/// thread / gateway bridge, disproportionate here), then proves a subagent tool call hits the -/// parent's PreToolUse gate (deny blocks it) and that the dispatch carries the `subagentType`. +/// Reproduces the prod inheritance seam (subagent.rs `ctx.client_hooks.clone()`) by +/// cloning the parent's hooks into a child `SessionActor`, so the subagent call hits +/// the parent's PreToolUse gate carrying the `subagentType`. #[tokio::test(flavor = "current_thread")] async fn subagent_inherits_parent_pre_tool_use_client_hook() { let local = tokio::task::LocalSet::new(); @@ -242,16 +174,11 @@ async fn subagent_inherits_parent_pre_tool_use_client_hook() { let parent = create_test_actor(0, 256_000, 85, parent_gateway_tx, parent_persistence_tx).await; - let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); - client_hooks.insert( + install_client_hook( + &parent, xai_grok_hooks::event::HookEventName::PreToolUse, - vec![crate::extensions::hooks::ClientHookGroup { - matcher: None, - callback_ids: vec!["cb_0".to_string()], - timeout: None, - }], + &["cb_0"], ); - *parent.client_hooks.borrow_mut() = client_hooks; let (child_gateway_tx, mut child_gateway_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -260,15 +187,12 @@ async fn subagent_inherits_parent_pre_tool_use_client_hook() { let subagent = create_test_actor(0, 256_000, 85, child_gateway_tx, child_persistence_tx).await; - // The inheritance seam under test (subagent.rs `ctx.client_hooks.clone()`): a child - // with no hooks of its own takes a clone of the parent's. assert!( subagent.client_hooks.borrow().is_empty(), "the subagent starts with no hooks of its own" ); *subagent.client_hooks.borrow_mut() = parent.client_hooks.borrow().clone(); - // Record the subagentType the parent's hook is dispatched with; answer the run deny. let seen_subagent_type = std::sync::Arc::new(std::sync::Mutex::new(None::)); let seen = seen_subagent_type.clone(); tokio::task::spawn_local(async move { @@ -304,7 +228,6 @@ async fn subagent_inherits_parent_pre_tool_use_client_hook() { ), }; let tool_call_id = acp::ToolCallId::new("call_1"); - // The subagent builds the envelope, tagging the call with its subagent type. let envelope = subagent.make_hook_envelope( xai_grok_hooks::event::HookEventName::PreToolUse, None, @@ -313,7 +236,6 @@ async fn subagent_inherits_parent_pre_tool_use_client_hook() { tool_use_id: call.id.clone(), tool_input: serde_json::json!({}), tool_input_truncated: false, - permission_mode: None, subagent_type: Some("code-reviewer".to_string()), }, ); @@ -353,17 +275,11 @@ async fn pre_tool_use_slow_callback_does_not_starve_a_deny() { tokio::sync::mpsc::unbounded_channel::(); let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; - let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); - client_hooks.insert( + install_client_hook( + &actor, xai_grok_hooks::event::HookEventName::PreToolUse, - vec![crate::extensions::hooks::ClientHookGroup { - matcher: None, - // "slow_cb" is registered first and never replies; "deny_cb" denies. - callback_ids: vec!["slow_cb".to_string(), "deny_cb".to_string()], - timeout: None, - }], + &["slow_cb", "deny_cb"], ); - *actor.client_hooks.borrow_mut() = client_hooks; tokio::task::spawn_local(async move { let mut held = Vec::new(); @@ -409,7 +325,6 @@ async fn pre_tool_use_slow_callback_does_not_starve_a_deny() { tool_use_id: call.id.clone(), tool_input: serde_json::json!({}), tool_input_truncated: false, - permission_mode: None, subagent_type: None, }, ); @@ -430,9 +345,7 @@ async fn pre_tool_use_slow_callback_does_not_starve_a_deny() { /// PostToolUse and PostToolUseFailure must never both fire for one tool call: a hard /// dispatch error fires only PostToolUseFailure; a successful dispatch fires only -/// PostToolUse. Guards the explicitly-hardened no-double-fire path (the PostToolUse -/// success block routes through `dispatch_hook`, the same as the failure arm). Each -/// post-tool event is observed as a fire-and-forget `x.ai/hooks/event` notification. +/// PostToolUse. #[tokio::test(flavor = "current_thread")] async fn post_tool_use_and_failure_never_double_fire() { let local = tokio::task::LocalSet::new(); @@ -462,7 +375,6 @@ async fn post_tool_use_and_failure_never_double_fire() { } *actor.client_hooks.borrow_mut() = client_hooks; - // Collect the `hookEventName` of every `x.ai/hooks/event` notification queued. let drain = |rx: &mut tokio::sync::mpsc::UnboundedReceiver| { let mut events = Vec::new(); @@ -500,7 +412,6 @@ async fn post_tool_use_and_failure_never_double_fire() { "an errored tool must fire only PostToolUseFailure, never PostToolUse" ); - // Success: bind the session so the tool dispatches cleanly. actor .workspace_ops .bind_local_session( @@ -524,62 +435,32 @@ async fn post_tool_use_and_failure_never_double_fire() { .await; } -/// A `pre_tool_use` deny must NOT cancel the turn. `execute_tool_calls` feeds the -/// deny reason back as the blocked tool's `tool_result` and returns -/// `ToolLoop::Continue`, so the turn loop keeps going and the model re-samples with -/// the reason in context and can adapt/retry (common agent-hook semantics). +/// A `pre_tool_use` deny must NOT cancel the turn: `execute_tool_calls` feeds the deny +/// reason back as the blocked tool's `tool_result` and returns `ToolLoop::Continue`, so +/// the model re-samples with the reason in context. /// -/// Regression guard for the bug where a hook deny surfaced as `ToolLoop::HookDenied`, -/// which `execute_tool_calls` treated as a terminal `final_result` and the turn loop -/// turned into `TurnOutcome::Cancelled` — ending the whole turn instead of letting -/// the model retry based on the reason. +/// Regression guard: the deny once surfaced as `ToolLoop::HookDenied`, which +/// `execute_tool_calls` treated as a terminal result, cancelling the whole turn. #[tokio::test(flavor = "current_thread")] async fn pre_tool_use_deny_feeds_reason_back_and_continues_turn() { let local = tokio::task::LocalSet::new(); local .run_until(async { - let (gateway_tx, mut gateway_rx) = + let (gateway_tx, gateway_rx) = tokio::sync::mpsc::unbounded_channel::(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel::(); let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; - // The agent's tool bridge must know `todo_write` so it parses + reaches - // the PreToolUse gate (rather than short-circuiting as an unknown tool). + // The tool bridge must know `todo_write` so the call reaches the gate + // rather than short-circuiting as an unknown tool. *actor.agent.borrow_mut() = test_grok_build_agent_with_todo().await; - let mut client_hooks = crate::extensions::hooks::ClientHooks::new(); - client_hooks.insert( + install_client_hook( + &actor, xai_grok_hooks::event::HookEventName::PreToolUse, - vec![crate::extensions::hooks::ClientHookGroup { - matcher: None, - callback_ids: vec!["cb_0".to_string()], - timeout: None, - }], + &["cb_0"], ); - *actor.client_hooks.borrow_mut() = client_hooks; - - // Answer the reverse x.ai/hooks/run request with a deny carrying a reason; - // ack the UI notifications `deny_tool` emits so it can't block the gate. - tokio::task::spawn_local(async move { - while let Some(msg) = gateway_rx.recv().await { - match msg { - xai_acp_lib::AcpClientMessage::ExtMethod(args) => { - let deny: Arc = - serde_json::value::to_raw_value(&serde_json::json!({ - "decision": "deny", - "systemMessage": "use read_file instead", - })) - .unwrap() - .into(); - let _ = args.response_tx.send(Ok(acp::ExtResponse::new(deny))); - } - xai_acp_lib::AcpClientMessage::SessionNotification(args) => { - let _ = args.response_tx.send(Ok(())); - } - _ => {} - } - } - }); + spawn_deny_responder(gateway_rx, "use read_file instead"); let call = ToolCallResponse { id: "call_1".to_string(), @@ -598,14 +479,11 @@ async fn pre_tool_use_deny_feeds_reason_back_and_continues_turn() { .expect("execute_tool_calls must not hang") .expect("execute_tool_calls must not error"); - // The turn must continue (deny fed back), NOT terminate. assert!( matches!(result, ToolLoop::Continue), "a pre_tool_use deny must continue the turn, got {result:?}" ); - // The deny reason must be pushed as the blocked tool's result so the - // model sees it on the next sampling and can retry. let conv = actor.chat_state_handle.get_conversation().await; assert!( conv.iter() @@ -615,3 +493,464 @@ async fn pre_tool_use_deny_feeds_reason_back_and_continues_turn() { }) .await; } + +/// The Stop client gate collects every deny as a block (no short-circuit). +#[tokio::test(flavor = "current_thread")] +async fn stop_client_gate_collects_denies() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, mut gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + + install_client_hook( + &actor, + xai_grok_hooks::event::HookEventName::Stop, + &["cb_block", "cb_allow"], + ); + + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + match msg { + xai_acp_lib::AcpClientMessage::ExtMethod(args) => { + let params: serde_json::Value = + serde_json::from_str(args.request.params.get()).unwrap(); + let response = if params["hookCallbackId"] == "cb_block" { + serde_json::json!({ + "decision": "deny", + "systemMessage": "finish the tests first", + }) + } else { + serde_json::json!({}) + }; + let response_params: Arc = + serde_json::value::to_raw_value(&response).unwrap().into(); + let _ = args + .response_tx + .send(Ok(acp::ExtResponse::new(response_params))); + } + xai_acp_lib::AcpClientMessage::SessionNotification(args) => { + let _ = args.response_tx.send(Ok(())); + } + _ => {} + } + } + }); + + let envelope = actor.make_hook_envelope( + xai_grok_hooks::event::HookEventName::Stop, + Some("prompt-1".to_string()), + xai_grok_hooks::event::HookPayload::Stop { + reason: "end_turn".to_string(), + stop_hook_active: true, + last_assistant_message: Some("I'm done".to_string()), + background_tasks: None, + session_crons: None, + }, + ); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_client_hooks(&envelope), + ) + .await + .expect("the stop gate must not hang"); + + assert_eq!(result.blocks.len(), 1, "only the denying callback blocks"); + assert_eq!(result.blocks[0].hook_name, "client:cb_block"); + assert_eq!(result.blocks[0].reason, "finish the tests first"); + assert!(result.prevent_continuation.is_none()); + assert!(result.additional_context.is_empty()); + }) + .await; +} + +/// `continue: false` becomes a force-stop (with `stopReason`) and `additionalContext` +/// becomes non-error feedback, matching what file hooks express. +#[tokio::test(flavor = "current_thread")] +async fn stop_client_gate_carries_continue_false_and_context() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, mut gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + + install_client_hook( + &actor, + xai_grok_hooks::event::HookEventName::Stop, + &["cb_stop", "cb_ctx"], + ); + + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + match msg { + xai_acp_lib::AcpClientMessage::ExtMethod(args) => { + let params: serde_json::Value = + serde_json::from_str(args.request.params.get()).unwrap(); + let response = if params["hookCallbackId"] == "cb_stop" { + serde_json::json!({ "continue": false, "stopReason": "budget" }) + } else { + serde_json::json!({ "additionalContext": "run the linter" }) + }; + let response_params: Arc = + serde_json::value::to_raw_value(&response).unwrap().into(); + let _ = args + .response_tx + .send(Ok(acp::ExtResponse::new(response_params))); + } + xai_acp_lib::AcpClientMessage::SessionNotification(args) => { + let _ = args.response_tx.send(Ok(())); + } + _ => {} + } + } + }); + + let envelope = actor.make_hook_envelope( + xai_grok_hooks::event::HookEventName::Stop, + Some("prompt-1".to_string()), + xai_grok_hooks::event::HookPayload::Stop { + reason: "end_turn".to_string(), + stop_hook_active: false, + last_assistant_message: None, + background_tasks: None, + session_crons: None, + }, + ); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_client_hooks(&envelope), + ) + .await + .expect("the stop gate must not hang"); + + assert!(result.blocks.is_empty()); + let prevent = result + .prevent_continuation + .expect("continue:false captured"); + assert_eq!(prevent.hook_name, "client:cb_stop"); + assert_eq!(prevent.reason, "budget"); + assert_eq!(result.additional_context, ["run the linter"]); + }) + .await; +} + +/// End-to-end through `run_stop_gate`: a client deny becomes `KeepWorking`, no hooks +/// allows the stop, and the consecutive-block cap overrides the gate. +#[tokio::test(flavor = "current_thread")] +async fn run_stop_gate_keep_working_and_cap() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + + let decision = actor.run_stop_gate("prompt-1", 0).await; + assert!(matches!(decision, StopGateDecision::AllowStop)); + + install_client_hook( + &actor, + xai_grok_hooks::event::HookEventName::Stop, + &["cb_0"], + ); + + spawn_deny_responder(gateway_rx, "keep working"); + + let decision = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_gate("prompt-1", 0), + ) + .await + .expect("the stop gate must not hang"); + match decision { + StopGateDecision::KeepWorking { feedback } => { + assert!( + feedback.contains("Stop hook feedback:") + && feedback.contains("keep working"), + "feedback must carry the deny message, got: {feedback}" + ); + } + _ => panic!("a client deny must keep the agent working"), + } + + let decision = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_gate("prompt-1", MAX_STOP_HOOK_CONTINUATIONS_PER_TURN), + ) + .await + .expect("the capped gate must not hang"); + assert!(matches!(decision, StopGateDecision::AllowStop)); + }) + .await; +} + +fn file_registry_with_stop_spec( + event: xai_grok_hooks::event::HookEventName, + script: &str, +) -> xai_grok_hooks::discovery::HookRegistry { + let (mut registry, _) = xai_grok_hooks::discovery::load_hooks(None, None); + registry.append_specs(vec![xai_grok_hooks::config::HookSpec { + name: "test/stop-hook".into(), + event, + handler_type: xai_grok_hooks::config::HandlerType::Command, + configured_matcher: None, + matcher: None, + enabled: true, + command: Some(std::path::PathBuf::from(script)), + command_raw: Some(script.to_string()), + url: None, + url_raw: None, + timeout_ms: 5000, + source_dir: std::path::PathBuf::from("/tmp"), + extra_env: std::collections::HashMap::new(), + }]); + registry +} + +/// A file-hook force-stop skips the client run gate (its signals would be discarded) +/// but still delivers the observe `x.ai/hooks/event` notification. +#[tokio::test(flavor = "current_thread")] +async fn file_force_stop_skips_client_gate_but_notifies() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, mut gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + actor.hook_resolved_workspace_root = "/tmp".to_string(); + + *actor.hook_registry.borrow_mut() = + Some(std::sync::Arc::new(file_registry_with_stop_spec( + xai_grok_hooks::event::HookEventName::Stop, + r#"echo '{"continue":false,"stopReason":"budget exhausted"}'"#, + ))); + install_client_hook( + &actor, + xai_grok_hooks::event::HookEventName::Stop, + &["cb_observer"], + ); + + let run_requests = std::rc::Rc::new(std::cell::Cell::new(0u32)); + let observe_events = std::rc::Rc::new(std::cell::Cell::new(0u32)); + let (runs, observes) = (run_requests.clone(), observe_events.clone()); + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + match msg { + xai_acp_lib::AcpClientMessage::ExtMethod(args) => { + if args.request.method.as_ref() == "x.ai/hooks/run" { + runs.set(runs.get() + 1); + } + let empty: Arc = + serde_json::value::to_raw_value(&serde_json::json!({})) + .unwrap() + .into(); + let _ = args.response_tx.send(Ok(acp::ExtResponse::new(empty))); + } + xai_acp_lib::AcpClientMessage::ExtNotification(args) => { + if args.request.method.as_ref() == "x.ai/hooks/event" { + observes.set(observes.get() + 1); + } + } + xai_acp_lib::AcpClientMessage::SessionNotification(args) => { + let _ = args.response_tx.send(Ok(())); + } + _ => {} + } + } + }); + + let decision = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_gate("prompt-1", 0), + ) + .await + .expect("the stop gate must not hang"); + assert!( + matches!(decision, StopGateDecision::AllowStop), + "a file force-stop must end the turn" + ); + // Yield so the fire-and-forget notification lands. + tokio::task::yield_now().await; + assert_eq!(run_requests.get(), 0, "the client run gate must be skipped"); + assert_eq!( + observe_events.get(), + 1, + "client callbacks must still see the turn end as an observe event" + ); + }) + .await; +} + +/// Two client callbacks both force-stop; attribution follows registration order even +/// when that callback responds last (completion order must not decide it). +#[tokio::test(flavor = "current_thread")] +async fn client_force_stop_attribution_is_registration_ordered() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, mut gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + + install_client_hook( + &actor, + xai_grok_hooks::event::HookEventName::Stop, + &["cb_first", "cb_second"], + ); + + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + match msg { + xai_acp_lib::AcpClientMessage::ExtMethod(args) => { + let params: serde_json::Value = + serde_json::from_str(args.request.params.get()).unwrap(); + let is_first = params["hookCallbackId"] == "cb_first"; + tokio::task::spawn_local(async move { + if is_first { + // The registration-order winner replies last. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let reason = if is_first { + "from-first" + } else { + "from-second" + }; + let body: Arc = + serde_json::value::to_raw_value(&serde_json::json!({ + "continue": false, + "stopReason": reason, + })) + .unwrap() + .into(); + let _ = args.response_tx.send(Ok(acp::ExtResponse::new(body))); + }); + } + xai_acp_lib::AcpClientMessage::SessionNotification(args) => { + let _ = args.response_tx.send(Ok(())); + } + _ => {} + } + } + }); + + let envelope = actor.make_hook_envelope( + xai_grok_hooks::event::HookEventName::Stop, + Some("prompt-1".to_string()), + xai_grok_hooks::event::HookPayload::Stop { + reason: "end_turn".to_string(), + stop_hook_active: false, + last_assistant_message: None, + background_tasks: None, + session_crons: None, + }, + ); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_client_hooks(&envelope), + ) + .await + .expect("the stop gate must not hang"); + + let prevent = result.prevent_continuation.expect("force-stop captured"); + assert_eq!( + prevent.hook_name, "client:cb_first", + "attribution must follow registration order, not completion order" + ); + assert_eq!(prevent.reason, "from-first"); + }) + .await; +} + +/// A subagent session gates on `SubagentStop` specs (not `Stop`), with the gate-phase +/// payload. +#[tokio::test(flavor = "current_thread")] +async fn subagent_session_gates_on_subagent_stop() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, mut gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + actor.startup_hints.is_subagent = true; + actor.hook_resolved_workspace_root = "/tmp".to_string(); + + *actor.hook_registry.borrow_mut() = + Some(std::sync::Arc::new(file_registry_with_stop_spec( + xai_grok_hooks::event::HookEventName::SubagentStop, + r#"echo '{"decision":"block","reason":"verify the summary"}'"#, + ))); + + tokio::task::spawn_local(async move { + while let Some(msg) = gateway_rx.recv().await { + if let xai_acp_lib::AcpClientMessage::SessionNotification(args) = msg { + let _ = args.response_tx.send(Ok(())); + } + } + }); + + let decision = tokio::time::timeout( + std::time::Duration::from_secs(5), + actor.run_stop_gate("prompt-1", 0), + ) + .await + .expect("the subagent stop gate must not hang"); + match decision { + StopGateDecision::KeepWorking { feedback } => { + assert!( + feedback.contains("verify the summary"), + "the SubagentStop block reason must become feedback, got: {feedback}" + ); + } + other => { + panic!("a SubagentStop block must keep the subagent working, got {other:?}") + } + } + }) + .await; +} + +/// Alias fire sites serialize the canonical event name: a `SubagentEnd` envelope reads +/// `"subagent_stop"` on the wire, matching `GROK_HOOK_EVENT`. +#[tokio::test(flavor = "current_thread")] +async fn alias_envelope_serializes_canonical_event_name() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + + let envelope = actor.make_hook_envelope( + xai_grok_hooks::event::HookEventName::SubagentEnd, + None, + xai_grok_hooks::event::HookPayload::SubagentStop { + phase: xai_grok_hooks::event::SubagentStopPhase::Observe, + subagent_id: "sub-1".into(), + subagent_type: "explore".into(), + stop_hook_active: None, + last_assistant_message: None, + }, + ); + let value = serde_json::to_value(&envelope).expect("envelope serializes"); + assert_eq!(value["hookEventName"], "subagent_stop"); + // The test actor runs yolo, so permissionMode pins that state. + assert_eq!(value["permissionMode"], "bypassPermissions"); + }) + .await; +} diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs index 28fa77f..2a7eb7c 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs @@ -24,25 +24,6 @@ async fn make_test_actor_with_active_goal() -> SessionActor { actor } -#[tokio::test(flavor = "current_thread")] -async fn goal_backoff_pauses_after_three_consecutive_failed_turns() { - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let actor = make_test_actor_with_active_goal().await; - for _ in 0..GOAL_CONTINUATION_BACKOFF_THRESHOLD { - actor.handle_turn_end(false).await; - } - let status = actor.goal_tracker.lock().status(); - assert_eq!( - status, - Some(crate::session::goal_tracker::GoalStatus::BackOffPaused) - ); - assert_eq!(actor.goal_continuation_streak.load(Ordering::Relaxed), 0); - }) - .await; -} - #[tokio::test(flavor = "current_thread")] async fn goal_backoff_resets_on_success() { let local = tokio::task::LocalSet::new(); @@ -115,7 +96,7 @@ async fn auto_pause_noop_when_goal_already_paused() { } #[tokio::test(flavor = "current_thread")] -async fn handle_turn_end_skip_increment_when_goal_not_active() { +async fn handle_turn_end_skips_increment_when_goal_not_active() { let local = tokio::task::LocalSet::new(); local .run_until(async { @@ -203,17 +184,9 @@ async fn seed_pending_classifier_nudge(actor: &SessionActor) { } /// Read `events.jsonl` and return the parsed `Event` records. -/// The test actor writes events to a session-unique events file; -/// we read it as the canonical event sink. /// -/// Synchronous-flush requirement: the live `EventWriter::emit` -/// path takes the file mutex, calls `write_all`, and releases — -/// no internal buffering — so this helper can read immediately -/// after the producer awaits the call site that emits the event. -/// If `EventWriter` ever switches to a buffered or background -/// writer, every caller of this helper will need to flush -/// explicitly (or this helper must grow a `wait_for_flush` arg); -/// guard the contract from drifting silently. +/// Relies on `EventWriter::emit` being synchronous (no buffering), so this reads +/// immediately after the producer awaits the emitting call site. fn read_events_jsonl(path: &std::path::Path) -> Vec { let Ok(body) = std::fs::read_to_string(path) else { return Vec::new(); @@ -708,7 +681,7 @@ async fn maybe_queue_goal_continuation_emits_premature_stop_at_most_once_across_ /// forces continuation with the gap inlined, so this precedence is /// intentional — pinned so a future gate change is caught. #[tokio::test(flavor = "current_thread")] -async fn handle_turn_end_classifier_nudge_pre_empts_bail_nudge_and_event() { +async fn handle_turn_end_classifier_nudge_preempts_bail_nudge_and_event() { use crate::sampling::ConversationItem; let local = tokio::task::LocalSet::new(); @@ -1019,6 +992,116 @@ fn format_turn_error_message_falls_back_to_classify_when_no_detail() { ); } +/// Matchers key on these serialized snake_case strings, so the set is a wire contract. +#[test] +fn stop_failure_error_type_covers_each_discriminable_class() { + use crate::sampling::error::{ + RATE_LIMITED_ERROR_CODE, error_data_with_status, terminal_error_data, + }; + let classify = |e: &acp::Error| SessionActor::stop_failure_error_type(e).as_str(); + + let rate = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited".to_string()); + assert_eq!(classify(&rate), "rate_limit"); + // Defensive: a 429 that arrives only as a data-carried status. + let rate_status = acp::Error::internal_error().data(error_data_with_status( + "too many requests".into(), + Some(429), + )); + assert_eq!(classify(&rate_status), "rate_limit"); + + assert_eq!( + classify(&acp::Error::auth_required()), + "authentication_failed" + ); + + // The sampler maps 400s to invalid_params (-32602); -32600 also counts. + assert_eq!(classify(&acp::Error::invalid_params()), "invalid_request"); + assert_eq!(classify(&acp::Error::invalid_request()), "invalid_request"); + + // 404 (model-not-found) folds into `invalid_request`, as an ACP resource + // error or a data-carried HTTP status. + assert_eq!( + classify(&acp::Error::resource_not_found(None)), + "invalid_request" + ); + let missing = acp::Error::internal_error() + .data(error_data_with_status("no such model".into(), Some(404))); + assert_eq!(classify(&missing), "invalid_request"); + + // 400/401 arrive as `internal_error` with the status in data; the + // status, not the code, must discriminate. + let auth = + acp::Error::internal_error().data(error_data_with_status("bad token".into(), Some(401))); + assert_eq!(classify(&auth), "authentication_failed"); + let bad_request = + acp::Error::internal_error().data(error_data_with_status("bad payload".into(), Some(400))); + assert_eq!(classify(&bad_request), "invalid_request"); + + // Capacity errors (503/529) fold into `rate_limit`. + let capacity = acp::Error::internal_error().data(error_data_with_status( + "upstream unavailable".into(), + Some(503), + )); + assert_eq!(classify(&capacity), "rate_limit"); + let capacity_529 = + acp::Error::internal_error().data(error_data_with_status("overloaded".into(), Some(529))); + assert_eq!(classify(&capacity_529), "rate_limit"); + + // 403 content-safety on the turn path carries http_status:403 and folds into + // `invalid_request` (the setup path, which has no status, is server_error; + // see the sampler-mapper test below). + let forbidden_turn = acp::Error::internal_error() + .data(error_data_with_status("content blocked".into(), Some(403))); + assert_eq!(classify(&forbidden_turn), "invalid_request"); + + let max_tokens = acp::Error::internal_error().data(terminal_error_data( + "output truncated".into(), + None, + xai_grok_sampler::SamplingErrorKind::MaxTokensTruncation, + )); + assert_eq!(classify(&max_tokens), "max_output_tokens"); + + assert_eq!(classify(&acp::Error::internal_error()), "server_error"); + assert_eq!(classify(&acp::Error::new(-31999, String::new())), "unknown"); +} + +/// End-to-end across `map_sampling_err_to_acp` and the classifier (not each seam in +/// isolation): a real capacity error classifies as `rate_limit`. +#[test] +fn capacity_error_from_sampler_mapper_classifies_as_rate_limit() { + let acp_err = + crate::sampling::error::map_sampling_err_to_acp(crate::sampling::SamplingError::Api { + status: reqwest::StatusCode::SERVICE_UNAVAILABLE, + message: "at capacity".into(), + model_metadata: None, + retry_after_secs: None, + should_retry: None, + }); + assert_eq!( + SessionActor::stop_failure_error_type(&acp_err).as_str(), + "rate_limit" + ); +} + +/// A 403 from the sampler setup mapper carries no HTTP status, so it classifies +/// as `server_error` via the `-32603` arm, unlike the turn path which folds +/// http_status:403 into `invalid_request`. +#[test] +fn forbidden_error_from_sampler_mapper_classifies_as_server_error() { + let acp_err = + crate::sampling::error::map_sampling_err_to_acp(crate::sampling::SamplingError::Api { + status: reqwest::StatusCode::FORBIDDEN, + message: "content policy".into(), + model_metadata: None, + retry_after_secs: None, + should_retry: None, + }); + assert_eq!( + SessionActor::stop_failure_error_type(&acp_err).as_str(), + "server_error" + ); +} + #[test] fn format_turn_error_message_prefers_data_message_over_err_message() { let err = acp::Error::new( diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs index 5c9805d..cebe291 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -304,7 +304,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index a6d1df6..d0250f6 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -229,7 +229,6 @@ async fn create_test_actor( sampler_handle: xai_grok_sampler::SamplerHandle::noop(), image_description_model: crate::test_support::TEST_MODEL.to_owned(), image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), @@ -673,7 +672,6 @@ async fn create_test_actor_with_memory( sampler_handle: xai_grok_sampler::SamplerHandle::noop(), image_description_model: crate::test_support::TEST_MODEL.to_owned(), image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), @@ -1437,7 +1435,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs index 0c79497..6be2fee 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -290,7 +290,6 @@ async fn create_test_actor_with_memory( rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(), image_description_model: crate::test_support::TEST_MODEL.to_owned(), image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/observability_bridge_mapping_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/observability_bridge_mapping_tests.rs index 43940f5..1969628 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/observability_bridge_mapping_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/observability_bridge_mapping_tests.rs @@ -39,7 +39,7 @@ fn turn_result_completed() { snapshot: Box::new(None), tools_called: vec![], structured_output: None, - refusal: false, + refusal: None, }); assert_eq!( turn_result_to_hook_outcome(&result), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs index c92062a..a15dd0d 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs @@ -238,7 +238,6 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(), image_description_model: crate::test_support::TEST_MODEL.to_owned(), image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs index 27d720f..ecb1cdc 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs @@ -359,7 +359,6 @@ pub(crate) async fn create_test_actor_ex( rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(), image_description_model: crate::test_support::TEST_MODEL.to_owned(), image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()), - subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()), subagent_token_records: parking_lot::Mutex::new(HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs b/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs index bb4b626..589465a 100644 --- a/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs +++ b/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs @@ -124,6 +124,7 @@ pub(crate) struct AgentRebuildSpec { pub session_id_str: String, pub respect_gitignore: bool, pub path_not_found_hints: bool, + pub scheduler_background_loops: bool, pub mcp_state: Arc>, pub managed_gateway_tool_client: Option, @@ -218,6 +219,7 @@ impl AgentRebuildSpec { session_id_str, respect_gitignore, path_not_found_hints, + scheduler_background_loops, mcp_state, managed_gateway_tool_client, is_non_interactive, @@ -346,6 +348,12 @@ impl AgentRebuildSpec { *respect_gitignore, )) .await; + agent + .tool_bridge() + .update_resource(xai_grok_tools::types::resources::SchedulerBackgroundLoops( + *scheduler_background_loops, + )) + .await; agent .tool_bridge() .update_resource(xai_grok_tools::types::resources::PathNotFoundHints( @@ -419,6 +427,7 @@ pub(crate) fn test_rebuild_spec_default() -> Arc { subagent_depth: 0, session_id_str: "test-session".to_string(), respect_gitignore: false, + scheduler_background_loops: true, path_not_found_hints: false, mcp_state: Arc::new(tokio::sync::Mutex::new( crate::session::mcp_servers::McpState::new(vec![]), diff --git a/crates/codegen/xai-grok-shell/src/session/compaction.rs b/crates/codegen/xai-grok-shell/src/session/compaction.rs index bac12c2..e1300fb 100644 --- a/crates/codegen/xai-grok-shell/src/session/compaction.rs +++ b/crates/codegen/xai-grok-shell/src/session/compaction.rs @@ -2368,7 +2368,6 @@ mod inline_auto_compact_flow_tests { image_describe_cache: Arc::new( crate::session::image_describe::ImageDescribeCache::new(), ), - subagent_spawn_info: parking_lot::Mutex::new(std::collections::HashMap::new()), subagent_token_records: parking_lot::Mutex::new(std::collections::HashMap::new()), workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), diff --git a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs index c331681..10cb10a 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs @@ -69,7 +69,7 @@ impl JsonlStorageAdapter { &self, dir: &std::path::Path, ) -> std::io::Result> { - let chat_file = dir.join("chat_history.jsonl"); + let chat_file = dir.join(super::CHAT_HISTORY_FILE); self.read_chat_history_sync(chat_file, CHAT_FORMAT_VERSION) } fn session_dir(&self, info: &Info) -> PathBuf { @@ -82,31 +82,42 @@ impl JsonlStorageAdapter { } } pub(super) fn updates_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("updates.jsonl") + self.session_dir(info).join(super::UPDATES_FILE) } fn chat_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("chat_history.jsonl") + self.session_dir(info).join(super::CHAT_HISTORY_FILE) + } + fn ensure_chat_history(&self, info: &Info, chat_format_version: u8) -> io::Result<()> { + if chat_format_version != crate::session::persistence::CHAT_FORMAT_VERSION { + return Ok(()); + } + let chat_file = self.chat_file(info); + if std::fs::metadata(&chat_file).map(|m| m.len()).unwrap_or(0) == 0 { + super::chat_rebuild::rebuild_chat_history(&self.session_dir(info))?; + } + Ok(()) } fn summary_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("summary.json") + self.session_dir(info).join(super::SUMMARY_FILE) } fn summary_lock_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("summary.json.lock") + self.session_dir(info) + .join(format!("{}.lock", super::SUMMARY_FILE)) } fn plan_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("plan.json") + self.session_dir(info).join(super::PLAN_FILE) } fn plan_mode_state_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("plan_mode.json") + self.session_dir(info).join(super::PLAN_MODE_FILE) } fn signals_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("signals.json") + self.session_dir(info).join(super::SIGNALS_FILE) } fn announcement_state_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("announcement_state.json") + self.session_dir(info).join(super::ANNOUNCEMENT_STATE_FILE) } fn goal_mode_state_file(&self, info: &Info) -> PathBuf { - self.session_dir(info).join("goal").join("state.json") + self.session_dir(info).join(super::GOAL_STATE_FILE) } fn rewind_points_file(&self, info: &Info) -> PathBuf { self.session_dir(info).join("rewind_points.jsonl") @@ -167,7 +178,7 @@ impl JsonlStorageAdapter { let session_dirs = self.scan_session_dirs(cwd); let mut summaries = Vec::new(); for session_dir in session_dirs { - let summary_path = session_dir.join("summary.json"); + let summary_path = session_dir.join(super::SUMMARY_FILE); match std::fs::read(&summary_path) { Ok(bytes) => { if let Ok(summary) = serde_json::from_slice::(&bytes) @@ -200,7 +211,7 @@ impl JsonlStorageAdapter { let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = Vec::with_capacity(session_dirs.len()); for session_dir in session_dirs { - let summary_path = session_dir.join("summary.json"); + let summary_path = session_dir.join(super::SUMMARY_FILE); if let Ok(meta) = std::fs::metadata(&summary_path) && let Ok(mtime) = meta.modified() { @@ -375,16 +386,7 @@ impl JsonlStorageAdapter { /// to a temp file then rename over the target, so a crash / `ENOSPC` mid-write /// can't truncate the existing file (e.g. lose `rewind_points.jsonl` history). async fn write_jsonl(&self, path: PathBuf, items: &[T]) -> io::Result<()> { - let mut content = Vec::new(); - for item in items { - let mut line = serde_json::to_vec(item) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - line.push(b'\n'); - content.extend(line); - } - let tmp = path.with_extension("jsonl.tmp"); - tokio::fs::write(&tmp, &content).await?; - tokio::fs::rename(&tmp, &path).await + super::write_jsonl_atomic_async(&path, items).await } fn read_jsonl(&self, path: PathBuf) -> io::Result> { if !path.exists() { @@ -488,9 +490,7 @@ impl JsonlStorageAdapter { let summary_path = self.summary_file(info); let bytes = serde_json::to_vec_pretty(summary) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let tmp = summary_path.with_extension("json.tmp"); - std::fs::write(&tmp, &bytes)?; - std::fs::rename(&tmp, &summary_path) + super::write_bytes_atomic(&summary_path, &bytes) } fn read_summary_sync(&self, info: &Info) -> io::Result { let path = self.summary_file(info); @@ -1194,10 +1194,7 @@ impl StorageAdapter for JsonlStorageAdapter { ) -> io::Result<()> { let json = serde_json::to_vec_pretty(state) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let target = self.plan_mode_state_file(info); - let tmp = target.with_extension("json.tmp"); - tokio::fs::write(&tmp, json).await?; - tokio::fs::rename(&tmp, &target).await + super::write_bytes_atomic_async(&self.plan_mode_state_file(info), json).await } async fn write_signals( &self, @@ -1206,10 +1203,7 @@ impl StorageAdapter for JsonlStorageAdapter { ) -> io::Result<()> { let signals_json = serde_json::to_vec(signals) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let target = self.signals_file(info); - let tmp = target.with_extension("json.tmp"); - tokio::fs::write(&tmp, signals_json).await?; - tokio::fs::rename(&tmp, &target).await + super::write_bytes_atomic_async(&self.signals_file(info), signals_json).await } async fn write_announcement_state( &self, @@ -1218,10 +1212,7 @@ impl StorageAdapter for JsonlStorageAdapter { ) -> io::Result<()> { let json = serde_json::to_vec(state).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let target = self.announcement_state_file(info); - let tmp = target.with_extension("json.tmp"); - tokio::fs::write(&tmp, json).await?; - tokio::fs::rename(&tmp, &target).await + super::write_bytes_atomic_async(&self.announcement_state_file(info), json).await } async fn write_goal_mode_state( &self, @@ -1234,14 +1225,13 @@ impl StorageAdapter for JsonlStorageAdapter { if let Some(parent) = target.parent() { tokio::fs::create_dir_all(parent).await?; } - let tmp = target.with_extension("json.tmp"); - tokio::fs::write(&tmp, json).await?; - tokio::fs::rename(&tmp, &target).await + super::write_bytes_atomic_async(&target, json).await } async fn load_session(&self, info: &Info) -> io::Result { let summary = self.read_summary_sync(info)?; - let chat_history = - self.read_chat_history_sync(self.chat_file(info), summary.chat_format_version)?; + let chat_file = self.chat_file(info); + self.ensure_chat_history(info, summary.chat_format_version)?; + let chat_history = self.read_chat_history_sync(chat_file, summary.chat_format_version)?; let updates = self.read_updates_jsonl(self.updates_file(info))?; let plan_state = self.read_optional_json_sync::(&self.plan_file(info))?; let plan_mode_state = self @@ -1289,8 +1279,9 @@ impl StorageAdapter for JsonlStorageAdapter { ) -> io::Result { tracing::info!("Loading session data (without updates) from JSONL"); let summary = self.read_summary_sync(info)?; - let chat_history = - self.read_chat_history_sync(self.chat_file(info), summary.chat_format_version)?; + let chat_file = self.chat_file(info); + self.ensure_chat_history(info, summary.chat_format_version)?; + let chat_history = self.read_chat_history_sync(chat_file, summary.chat_format_version)?; let plan_state = self.read_optional_json_sync::(&self.plan_file(info))?; let plan_mode_state = self .read_optional_json_sync::( diff --git a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs index c38b269..2e33ee2 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs @@ -130,6 +130,43 @@ async fn test_jsonl_round_trip() { assert_eq!(loaded.updates.len(), 1); assert!(loaded.plan_state.is_some()); } +/// Resume from updates.jsonl alone: when chat_history.jsonl is missing, load +/// rebuilds it from the ACP update stream (the durable source of truth). +#[tokio::test] +async fn load_rebuilds_chat_history_from_updates() { + use agent_client_protocol::{ + ContentBlock, ContentChunk, SessionUpdate as Acp, TextContent, + }; + let temp_dir = TempDir::new().unwrap(); + let info = create_test_info(); + let adapter = JsonlStorageAdapter::with_root(temp_dir.path().to_path_buf()); + adapter.init_session(&info, default_model_id()).await.unwrap(); + let text = |s: &str| ContentChunk::new( + ContentBlock::Text(TextContent::new(s.to_string())), + ); + let notify = |u| SessionUpdate::Acp( + Box::new(acp::SessionNotification::new(info.id.clone(), u)), + ); + adapter + .append_update(&info, ¬ify(Acp::UserMessageChunk(text("ping")))) + .await + .unwrap(); + adapter + .append_update(&info, ¬ify(Acp::AgentMessageChunk(text("pong")))) + .await + .unwrap(); + let chat_path = adapter.session_dir(&info).join("chat_history.jsonl"); + assert_eq!(std::fs::metadata(& chat_path).map(| m | m.len()).unwrap_or(0), 0); + let loaded = adapter.load_session(&info).await.unwrap(); + assert_eq!(loaded.chat_history.len(), 2, "one user + one agent conversation item"); + assert!(matches!(loaded.chat_history[0], ConversationItem::User(_))); + assert!(matches!(loaded.chat_history[1], ConversationItem::Assistant(_))); + let persisted = std::fs::read_to_string(&chat_path).unwrap(); + assert!( + persisted.contains("ping") && persisted.contains("pong"), + "rebuilt cache carries the transcript text" + ); +} /// `load_session_without_updates` always defers rewind points while the full /// `load_session` / `load_rewind_points` still return them. #[tokio::test] diff --git a/crates/codegen/xai-grok-shell/src/session/storage/mod.rs b/crates/codegen/xai-grok-shell/src/session/storage/mod.rs index c10914d..05ed6ae 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/mod.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::extensions::notification::SessionNotification; use crate::sampling::ConversationItem; @@ -21,6 +21,405 @@ pub mod search_fts; pub mod search_remote_sync; pub(crate) mod summary_write; +/// On-disk file names, relative to a session directory. Single source of truth for +/// the storage adapter and the session/state and session/import extensions. +pub(crate) const SUMMARY_FILE: &str = "summary.json"; +pub(crate) const PLAN_FILE: &str = "plan.json"; +pub(crate) const PLAN_MODE_FILE: &str = "plan_mode.json"; +pub(crate) const SIGNALS_FILE: &str = "signals.json"; +pub(crate) const GOAL_STATE_FILE: &str = "goal/state.json"; +pub(crate) const ANNOUNCEMENT_STATE_FILE: &str = "announcement_state.json"; +pub(crate) const CHAT_HISTORY_FILE: &str = "chat_history.jsonl"; +pub(crate) const UPDATES_FILE: &str = "updates.jsonl"; + +/// Write `bytes` to `path` by writing a uniquely named sibling temp file and +/// renaming it over the target, so a crash or a concurrent writer never leaves a +/// torn file. The temp is removed on failure. +pub(crate) fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let tmp = temp_sibling(path); + match std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)) { + Ok(()) => Ok(()), + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// Async sibling of [`write_bytes_atomic`]. +pub(crate) async fn write_bytes_atomic_async(path: &Path, bytes: Vec) -> io::Result<()> { + let tmp = temp_sibling(path); + let result = match tokio::fs::write(&tmp, bytes).await { + Ok(()) => tokio::fs::rename(&tmp, path).await, + Err(e) => Err(e), + }; + if result.is_err() { + let _ = tokio::fs::remove_file(&tmp).await; + } + result +} + +/// Serialize `items` to newline-delimited JSON bytes. +fn to_jsonl_bytes(items: &[T]) -> io::Result> { + let mut content = Vec::new(); + for item in items { + serde_json::to_writer(&mut content, item) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + content.push(b'\n'); + } + Ok(content) +} + +/// Write `items` as newline-delimited JSON to `path`, atomically (see +/// [`write_bytes_atomic`]). +pub(crate) fn write_jsonl_atomic(path: &Path, items: &[T]) -> io::Result<()> { + write_bytes_atomic(path, &to_jsonl_bytes(items)?) +} + +/// Async sibling of [`write_jsonl_atomic`]. +pub(crate) async fn write_jsonl_atomic_async( + path: &Path, + items: &[T], +) -> io::Result<()> { + write_bytes_atomic_async(path, to_jsonl_bytes(items)?).await +} + +/// A unique sibling temp path, e.g. `summary.json` -> `summary.json..tmp`. +fn temp_sibling(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(format!(".{}.tmp", uuid::Uuid::now_v7())); + PathBuf::from(name) +} + +/// Rebuild the derived `chat_history.jsonl` cache from `updates.jsonl`, the durable +/// source of truth, so a session restores from its update stream alone. +pub(crate) mod chat_rebuild { + use std::collections::{HashMap, HashSet}; + use std::io; + use std::path::Path; + + use agent_client_protocol as acp; + + use super::{CHAT_HISTORY_FILE, SessionUpdate, UPDATES_FILE, UpdatesIterator}; + use crate::sampling::{AssistantItem, ContentPart, ConversationItem, ToolCall}; + + /// Rebuild `chat_history.jsonl` from `updates.jsonl` alone. Builds a temp file and + /// renames it over the target, so a failed rebuild leaves the existing cache intact + /// rather than a truncated partial that load would trust. + pub(crate) fn rebuild_chat_history(dir: &Path) -> io::Result { + use std::io::{Seek, Write}; + + let updates_path = dir.join(UPDATES_FILE); + let Some(iter) = UpdatesIterator::open(&updates_path)? else { + return Ok(0); + }; + + let chat_path = dir.join(CHAT_HISTORY_FILE); + let tmp_path = dir.join(format!("{CHAT_HISTORY_FILE}.{}.tmp", uuid::Uuid::now_v7())); + let file = std::fs::File::create(&tmp_path)?; + let mut writer = std::io::BufWriter::new(file); + let mut reducer = ChatReducer::new(); + + for result in iter { + let update = match result { + Ok(u) => u, + Err(_) => continue, + }; + + for item in reducer.process(&update) { + if let Ok(line) = serde_json::to_string(&item) { + let _ = writer.write_all(line.as_bytes()); + let _ = writer.write_all(b"\n"); + } + } + + // CompactionCheckpoint: truncate file and reset + if reducer.should_truncate() { + reducer.clear_truncate_flag(); + let _ = writer.seek(std::io::SeekFrom::Start(0)); + let _ = writer.get_mut().set_len(0); + } + } + + for item in reducer.flush() { + if let Ok(line) = serde_json::to_string(&item) { + let _ = writer.write_all(line.as_bytes()); + let _ = writer.write_all(b"\n"); + } + } + + if let Err(e) = writer.flush() { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); + } + drop(writer); + if let Err(e) = std::fs::rename(&tmp_path, &chat_path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); + } + Ok(reducer.count()) + } + + /// Reduces ACP session updates into conversation items. + /// + /// Turn boundaries: User→Agent flushes user, Agent→User flushes agent, + /// tool completion flushes agent before emitting result. + struct ChatReducer { + user_parts: Vec, + agent_text: String, + agent_tool_calls: Vec, + + in_user_turn: bool, + has_agent_content: bool, + needs_truncate: bool, + + tool_args: HashMap, + emitted_tool_results: HashSet, + item_count: usize, + } + + impl ChatReducer { + fn new() -> Self { + Self { + user_parts: Vec::new(), + agent_text: String::new(), + agent_tool_calls: Vec::new(), + in_user_turn: false, + has_agent_content: false, + needs_truncate: false, + tool_args: HashMap::new(), + emitted_tool_results: HashSet::new(), + item_count: 0, + } + } + + fn process(&mut self, update: &SessionUpdate) -> Vec { + match update { + SessionUpdate::Acp(n) => self.handle_acp(&n.update), + SessionUpdate::Xai(n) => self.handle_xai(&n.update), + } + } + + fn handle_acp(&mut self, update: &acp::SessionUpdate) -> Vec { + match update { + acp::SessionUpdate::UserMessageChunk(chunk) => self.on_user_chunk(chunk), + acp::SessionUpdate::AgentMessageChunk(chunk) => self.on_agent_chunk(chunk), + acp::SessionUpdate::ToolCall(tc) => self.on_tool_call(tc), + acp::SessionUpdate::ToolCallUpdate(tc) => self.on_tool_call_update(tc), + _ => Vec::new(), // AgentThoughtChunk, Retry, Plan not needed + } + } + + fn handle_xai( + &mut self, + update: &crate::extensions::notification::SessionUpdate, + ) -> Vec { + use crate::extensions::notification::SessionUpdate as XaiUpdate; + + match update { + XaiUpdate::CompactionCheckpoint(_) => { + self.reset(); + self.needs_truncate = true; + Vec::new() + } + _ => Vec::new(), // DiffReview, MemoryFlush, etc. not needed + } + } + + fn on_user_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec { + let mut out = Vec::new(); + + if !self.in_user_turn { + out.extend(self.flush_agent()); + self.in_user_turn = true; + } + + match &chunk.content { + acp::ContentBlock::Text(t) => { + self.user_parts.push(ContentPart::Text { + text: std::sync::Arc::::from(t.text.clone()), + }); + } + acp::ContentBlock::Image(img) => { + if let Some(uri) = &img.uri { + self.user_parts.push(ContentPart::Image { + url: std::sync::Arc::::from(uri.clone()), + }); + } + } + _ => {} // Audio, Resource, etc. not needed for chat replay + } + + out + } + + fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec { + let mut out = Vec::new(); + + if self.in_user_turn { + out.extend(self.flush_user()); + self.in_user_turn = false; + } + + if let acp::ContentBlock::Text(t) = &chunk.content { + self.agent_text.push_str(&t.text); + self.has_agent_content = true; + } + + out + } + + fn on_tool_call(&mut self, tc: &acp::ToolCall) -> Vec { + let id = tc.tool_call_id.0.to_string(); + let args = tc + .raw_input + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_default(); + + self.tool_args.insert(id.clone(), args.clone()); + self.agent_tool_calls.push(ToolCall { + id: std::sync::Arc::::from(id), + name: tc.title.clone(), + arguments: std::sync::Arc::::from(args), + }); + + Vec::new() + } + + fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec { + let id = tc.tool_call_id.0.to_string(); + self.maybe_backfill_args(&id, &tc.fields); + + if Self::is_completed(&tc.fields) && self.emitted_tool_results.insert(id.clone()) { + return self.emit_tool_result(&id, &tc.fields); + } + Vec::new() + } + + /// Backfill tool arguments from ToolCallUpdate if ToolCall didn't have them. + fn maybe_backfill_args(&mut self, id: &str, fields: &acp::ToolCallUpdateFields) { + let Some(raw) = &fields.raw_input else { return }; + let needs_backfill = self.tool_args.get(id).is_none_or(String::is_empty); + if !needs_backfill { + return; + } + + let args = raw.to_string(); + self.tool_args.insert(id.to_string(), args.clone()); + + if let Some(call) = self + .agent_tool_calls + .iter_mut() + .find(|c| c.id.as_ref() == id) + { + call.arguments = std::sync::Arc::::from(args); + } + } + + fn is_completed(fields: &acp::ToolCallUpdateFields) -> bool { + matches!( + fields.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ) + } + + fn emit_tool_result( + &mut self, + id: &str, + fields: &acp::ToolCallUpdateFields, + ) -> Vec { + let mut out = Vec::new(); + out.extend(self.flush_agent()); + + let content = extract_tool_result_text(fields); + let item = ConversationItem::tool_result(id.to_string(), content); + self.item_count += 1; + out.push(item); + out + } + + fn flush_user(&mut self) -> Option { + if self.user_parts.is_empty() { + return None; + } + let item = ConversationItem::user_with_parts(std::mem::take(&mut self.user_parts)); + self.item_count += 1; + Some(item) + } + + fn flush_agent(&mut self) -> Option { + if !self.has_agent_content && self.agent_tool_calls.is_empty() { + return None; + } + let item = ConversationItem::Assistant(AssistantItem { + content: std::sync::Arc::::from(std::mem::take(&mut self.agent_text)), + tool_calls: std::mem::take(&mut self.agent_tool_calls), + model_id: None, + model_fingerprint: None, + reasoning_effort: None, + }); + self.has_agent_content = false; + self.item_count += 1; + Some(item) + } + + fn flush(&mut self) -> Vec { + let mut out = Vec::new(); + out.extend(self.flush_user()); + out.extend(self.flush_agent()); + out + } + + fn reset(&mut self) { + self.user_parts.clear(); + self.agent_text.clear(); + self.agent_tool_calls.clear(); + self.tool_args.clear(); + self.emitted_tool_results.clear(); + self.in_user_turn = false; + self.has_agent_content = false; + self.item_count = 0; + } + + fn should_truncate(&self) -> bool { + self.needs_truncate + } + + fn clear_truncate_flag(&mut self) { + self.needs_truncate = false; + } + + fn count(&self) -> usize { + self.item_count + } + } + + /// Extract displayable text from a completed ToolCallUpdate. + fn extract_tool_result_text(fields: &acp::ToolCallUpdateFields) -> String { + if let Some(content) = &fields.content { + let text: String = content + .iter() + .filter_map(|c| match c { + acp::ToolCallContent::Content(acp::Content { + content: acp::ContentBlock::Text(t), + .. + }) => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + if !text.is_empty() { + return text; + } + } + if let Some(raw) = &fields.raw_output { + return raw.to_string(); + } + String::new() + } +} + /// Iterator that streams session updates from a JSONL file without loading all into memory. /// Each call to `next()` reads and parses one line. pub struct UpdatesIterator { @@ -984,7 +1383,7 @@ pub fn load_updates_for_replay_at( fn load_updates_for_replay_from_dir( session_dir: &std::path::Path, ) -> std::io::Result>> { - let updates_path = session_dir.join("updates.jsonl"); + let updates_path = session_dir.join(UPDATES_FILE); let Some(iter) = UpdatesIterator::open(&updates_path)? else { return Ok(None); }; diff --git a/crates/codegen/xai-grok-shell/src/session/storage/summary_write.rs b/crates/codegen/xai-grok-shell/src/session/storage/summary_write.rs index 31fc0d5..14c978a 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/summary_write.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/summary_write.rs @@ -228,9 +228,7 @@ fn read_summary(path: &Path) -> io::Result { fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> { let bytes = serde_json::to_vec_pretty(summary) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let tmp = summary_path.with_extension("json.tmp"); - std::fs::write(&tmp, &bytes)?; - std::fs::rename(&tmp, summary_path) + crate::session::storage::write_bytes_atomic(summary_path, &bytes) } #[cfg(test)] diff --git a/crates/codegen/xai-grok-shell/src/session/telemetry.rs b/crates/codegen/xai-grok-shell/src/session/telemetry.rs index f393e7e..cdaccf1 100644 --- a/crates/codegen/xai-grok-shell/src/session/telemetry.rs +++ b/crates/codegen/xai-grok-shell/src/session/telemetry.rs @@ -120,7 +120,7 @@ impl HookRegInfo { Self { name: format_hook_name(spec), event: spec.event.to_string(), - hook_type: spec.handler_type.clone(), + hook_type: spec.handler_type.as_str().to_string(), source: format_hook_source(spec), } } diff --git a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs index 85c7aee..4bc23cf 100644 --- a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs +++ b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs @@ -662,23 +662,26 @@ async fn handle_notification( tracing::info!( task_id = %fired.task_id, schedule = %fired.human_schedule, - "Scheduled task fired, injecting prompt into session" + subagent_id = fired.subagent_id.as_deref().unwrap_or(""), + "Scheduled task fired" ); - let inject_payload = serde_json::json!({ - "sessionId": config.session_id, - "taskId": &fired.task_id, - "prompt": &fired.prompt, - "humanSchedule": &fired.human_schedule, - "nextFireAt": &fired.next_fire_at, - }); - if let Ok(params) = serde_json::value::to_raw_value(&inject_payload) { - config - .gateway - .forward_fire_and_forget(acp::ExtNotification::new( - "x.ai/scheduled_task_inject_prompt", - params.into(), - )); + if fired.subagent_id.is_none() { + let inject_payload = serde_json::json!({ + "sessionId": config.session_id, + "taskId": &fired.task_id, + "prompt": &fired.prompt, + "humanSchedule": &fired.human_schedule, + "nextFireAt": &fired.next_fire_at, + }); + if let Ok(params) = serde_json::value::to_raw_value(&inject_payload) { + config + .gateway + .forward_fire_and_forget(acp::ExtNotification::new( + "x.ai/scheduled_task_inject_prompt", + params.into(), + )); + } } let fired_notif = crate::extensions::notification::SessionNotification { @@ -688,6 +691,7 @@ async fn handle_notification( prompt: fired.prompt, human_schedule: fired.human_schedule, next_fire_at: fired.next_fire_at, + subagent_id: fired.subagent_id, }, meta: None, }; @@ -1852,6 +1856,7 @@ mod tests { prompt: "check deploy".into(), human_schedule: "every 5 minutes".into(), next_fire_at: Some("2026-01-01T00:00:00Z".into()), + subagent_id: None, }, ); let mut offsets = HashMap::new(); diff --git a/crates/codegen/xai-grok-shell/src/upload/trace.rs b/crates/codegen/xai-grok-shell/src/upload/trace.rs index dd024a1..8e4d87b 100644 --- a/crates/codegen/xai-grok-shell/src/upload/trace.rs +++ b/crates/codegen/xai-grok-shell/src/upload/trace.rs @@ -194,8 +194,7 @@ fn upload_failure_log_level(method: &UploadMethod, prior_failures: u64) -> Uploa UploadFailureLogLevel::Error } } -/// Wire label for the upload backend; reuses the `upload_reason` span-field -/// vocabulary so dashboards join on one set of values. +/// Wire label for the upload backend used by structured session events. fn upload_method_label(method: &UploadMethod) -> &'static str { use super::turn::TraceUploadReason; match method { @@ -2539,7 +2538,7 @@ mod tests { } /// Customer-managed S3 failures stay below the ERROR alerting threshold, /// repeats within an episode drop to debug, and the `method` log field - /// keeps the `upload_reason` span-field vocabulary. + /// keeps the structured upload-method vocabulary. #[test] fn upload_failure_log_level_splits_on_backend_and_repeats() { use crate::session::repo_changes::UploadMethod; diff --git a/crates/codegen/xai-grok-shell/src/upload/turn.rs b/crates/codegen/xai-grok-shell/src/upload/turn.rs index 089ddc6..e970640 100644 --- a/crates/codegen/xai-grok-shell/src/upload/turn.rs +++ b/crates/codegen/xai-grok-shell/src/upload/turn.rs @@ -49,7 +49,6 @@ pub(crate) enum UploadWait { Defer { deadline: tokio::time::Instant }, } /// Why trace uploads are enabled or disabled for a given prompt. -/// Recorded on the `agent.prompt` span as `upload_reason` for log queries. pub(crate) use xai_grok_telemetry::session_metrics::TraceUploadReason; /// Per-turn context for trace artifact uploads. #[derive(Clone)] diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs index c5f7b6d..80d9fa8 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs @@ -193,6 +193,142 @@ mod login_shell_capture_tests { } } +const ENV_SCHEDULER_BACKGROUND_LOOPS: &str = "GROK_SCHEDULER_BACKGROUND_LOOPS"; + +fn scheduler_background_loops_from_toml(v: Option<&TomlValue>) -> Option { + v?.get("scheduler")?.get("background_loops")?.as_bool() +} + +/// Resolve whether scheduled task fires run in background loop subagents. +/// +/// Precedence: requirements > env (`GROK_SCHEDULER_BACKGROUND_LOOPS`) > user +/// `config.toml` `[scheduler] background_loops` > managed layers > remote +/// settings > default `true`. +pub fn resolve_scheduler_background_loops(remote: Option) -> bool { + let requirements = crate::config::load_merged_requirements(); + let layers = match crate::config::ConfigLayers::load() { + Ok(l) => Some(l), + Err(e) => { + tracing::warn!(error = %e, "scheduler_background_loops: failed to load config layers"); + None + } + }; + resolve_scheduler_background_loops_tiers( + requirements.as_ref(), + layers.as_ref().map(|l| &l.user), + layers.as_ref().map(|l| &l.managed), + layers.as_ref().map(|l| &l.system_managed), + remote, + ) +} + +fn resolve_scheduler_background_loops_tiers( + requirements: Option<&TomlValue>, + user: Option<&TomlValue>, + managed: Option<&TomlValue>, + system_managed: Option<&TomlValue>, + remote: Option, +) -> bool { + use crate::agent::config::BoolFlag; + BoolFlag::env(ENV_SCHEDULER_BACKGROUND_LOOPS) + .requirement(scheduler_background_loops_from_toml(requirements)) + .config(scheduler_background_loops_from_toml(user)) + .managed( + scheduler_background_loops_from_toml(managed) + .or_else(|| scheduler_background_loops_from_toml(system_managed)), + ) + .feature_flag(remote) + .default(true) + .resolve() + .value +} + +#[cfg(test)] +mod scheduler_background_loops_tests { + use super::{ENV_SCHEDULER_BACKGROUND_LOOPS, resolve_scheduler_background_loops_tiers}; + use toml::Value as TomlValue; + + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn guard() -> std::sync::MutexGuard<'static, ()> { + let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + unsafe { std::env::remove_var(ENV_SCHEDULER_BACKGROUND_LOOPS) }; + g + } + + fn cfg(enabled: bool) -> TomlValue { + toml::from_str(&format!("[scheduler]\nbackground_loops = {enabled}\n")).unwrap() + } + + #[test] + fn defaults_on() { + let _g = guard(); + assert!(resolve_scheduler_background_loops_tiers( + None, None, None, None, None + )); + } + + #[test] + fn remote_flag_can_disable() { + let _g = guard(); + assert!(!resolve_scheduler_background_loops_tiers( + None, + None, + None, + None, + Some(false) + )); + } + + #[test] + fn user_config_beats_remote() { + let _g = guard(); + assert!(resolve_scheduler_background_loops_tiers( + None, + Some(&cfg(true)), + None, + None, + Some(false) + )); + assert!(!resolve_scheduler_background_loops_tiers( + None, + Some(&cfg(false)), + None, + None, + Some(true) + )); + } + + #[test] + fn env_beats_config_and_remote() { + let _g = guard(); + unsafe { std::env::set_var(ENV_SCHEDULER_BACKGROUND_LOOPS, "0") }; + let off = resolve_scheduler_background_loops_tiers( + None, + Some(&cfg(true)), + None, + None, + Some(true), + ); + unsafe { std::env::remove_var(ENV_SCHEDULER_BACKGROUND_LOOPS) }; + assert!(!off); + } + + #[test] + fn requirements_win_outright() { + let _g = guard(); + unsafe { std::env::set_var(ENV_SCHEDULER_BACKGROUND_LOOPS, "1") }; + let off = resolve_scheduler_background_loops_tiers( + Some(&cfg(false)), + Some(&cfg(true)), + None, + None, + Some(true), + ); + unsafe { std::env::remove_var(ENV_SCHEDULER_BACKGROUND_LOOPS) }; + assert!(!off); + } +} + /// Env override for `[toolset.ask_user_question] timeout_enabled` (parsed by /// the shared [`xai_grok_config::env_bool`] via `BoolFlag`). The secs env var /// lives in the tools crate (`RESPONSE_TIMEOUT_ENV`), parsed once there. diff --git a/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs b/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs index deaa17b..c26f686 100644 --- a/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs +++ b/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs @@ -22,12 +22,10 @@ //! ``` use std::future::Future; -use std::path::Path; use std::process::Command; use std::time::Duration; use serde_json::Value; -use xai_grok_test_support::env::test_env_cmd_tokio; use xai_grok_test_support::*; /// Run an async test body inside a `LocalSet` (required by ACP's `!Send` futures). @@ -107,25 +105,6 @@ fn inference_tool_names(server: &MockInferenceServer) -> Vec { .collect() } -async fn run_headless_with_env( - server: &MockInferenceServer, - args: &[&str], - cwd: &Path, - env: &[(&str, &str)], -) -> HeadlessResult { - let home = tempfile::TempDir::new().expect("create temp home"); - let mut cmd = tokio::process::Command::new(grok_binary()); - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .envs(env.iter().copied()); - test_env_cmd_tokio(&mut cmd, &server.url(), home.path()); - run_headless_with_cmd(cmd).await -} - // ============================================================================ // Smoke tests // ============================================================================ diff --git a/crates/codegen/xai-grok-shell/tests/test_stop_hook_e2e.rs b/crates/codegen/xai-grok-shell/tests/test_stop_hook_e2e.rs new file mode 100644 index 0000000..d45bcda --- /dev/null +++ b/crates/codegen/xai-grok-shell/tests/test_stop_hook_e2e.rs @@ -0,0 +1,198 @@ +//! Built-binary e2e smoke tests for Stop hook decision control. `#[ignore]`d by +//! default since they need the grok binary (`GROK_BINARY` or a local debug build): +//! ```bash +//! cargo test -p xai-grok-shell --test test_stop_hook_e2e -- --ignored +//! ``` + +use xai_grok_test_support::env::test_env_cmd_tokio; +use xai_grok_test_support::*; + +/// Everything a test needs to assert on after a headless run with a Stop hook. +struct StopHookRun { + result: HeadlessResult, + server: MockInferenceServer, + state_dir: tempfile::TempDir, + _home: tempfile::TempDir, + _workdir: tempfile::TempDir, +} + +impl StopHookRun { + fn invocations(&self) -> u32 { + std::fs::read_to_string(self.state_dir.path().join("count")) + .map(|s| s.trim().parse().expect("count file holds a number")) + .unwrap_or(0) + } + + /// The stdin envelope the hook received on its `n`-th run (1-based). + fn hook_input(&self, n: u32) -> serde_json::Value { + let path = self.state_dir.path().join(format!("input_{n}.json")); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("hook stdin not JSON: {e}\n{text}")) + } + + /// Substring match over the serialized request JSON; keep needles free of + /// quotes and newlines. + fn some_request_contains(&self, needle: &str) -> bool { + self.server + .request_bodies() + .iter() + .any(|body| body.to_string().contains(needle)) + } +} + +/// Runs the built binary headless with a global Stop hook whose script body is +/// `respond`. `$n` holds the 1-based invocation number when `respond` runs. +async fn run_with_stop_hook(respond: &str) -> StopHookRun { + let home = tempfile::TempDir::new().expect("create temp home"); + let state_dir = tempfile::TempDir::new().expect("create state dir"); + let workdir = git_workdir(); + let server = MockInferenceServer::start() + .await + .expect("start mock server"); + + let state = state_dir.path().display(); + let script_path = home.path().join("stop_hook.sh"); + // Only turn-end gate fires (`reason: "end_turn"`) are counted and + // responded to, so a session-end Stop fire (`channel_closed`/`shutdown`) + // can never skew the counts these tests assert on. + std::fs::write( + &script_path, + format!( + "#!/bin/sh\n\ + cat > {state}/stdin.json\n\ + grep -q '\"reason\":\"end_turn\"' {state}/stdin.json || exit 0\n\ + n=$(cat {state}/count 2>/dev/null || echo 0)\n\ + n=$((n+1))\n\ + echo $n > {state}/count\n\ + mv {state}/stdin.json {state}/input_$n.json\n\ + {respond}\n" + ), + ) + .expect("write hook script"); + + let hooks_dir = home.path().join(".grok").join("hooks"); + std::fs::create_dir_all(&hooks_dir).expect("create hooks dir"); + std::fs::write( + hooks_dir.join("stop.json"), + serde_json::json!({ + "hooks": { + "Stop": [{ + "hooks": [{ + "type": "command", + "command": format!("sh {}", script_path.display()), + "timeout": 30 + }] + }] + } + }) + .to_string(), + ) + .expect("write hook config"); + + let mut cmd = tokio::process::Command::new(grok_binary()); + cmd.args(["-p", "say hello", "--yolo"]) + .current_dir(workdir.path()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + test_env_cmd_tokio(&mut cmd, &server.url(), home.path()); + let result = run_headless_with_cmd(cmd).await; + + StopHookRun { + result, + server, + state_dir, + _home: home, + _workdir: workdir, + } +} + +fn assert_success(run: &StopHookRun, label: &str) { + assert_headless_success(&run.result, label, Some(&run.server)); +} + +#[tokio::test] +#[ignore] +async fn stop_block_keeps_agent_working_then_allows() { + let run = run_with_stop_hook( + r#"if [ $n -eq 1 ]; then echo '{"decision":"block","reason":"finish the checklist first"}'; fi"#, + ) + .await; + assert_success(&run, "stop block e2e"); + + assert_eq!( + run.invocations(), + 2, + "gate must re-fire once after the block, then allow" + ); + + let first = run.hook_input(1); + assert_eq!(first["stopHookActive"], false, "first fire: no prior block"); + assert!( + first["lastAssistantMessage"].is_string(), + "input carries the turn's final response, got: {first}" + ); + + let second = run.hook_input(2); + assert_eq!( + second["stopHookActive"], true, + "re-fire must set stopHookActive" + ); + + assert!( + run.some_request_contains("finish the checklist first"), + "the block reason must be fed back to the model" + ); +} + +#[tokio::test] +#[ignore] +async fn stop_exit_2_blocks_with_stderr_feedback() { + let run = run_with_stop_hook( + r#"if [ $n -eq 1 ]; then echo 'run the linter before finishing' >&2; exit 2; fi"#, + ) + .await; + assert_success(&run, "stop exit-2 e2e"); + + assert_eq!(run.invocations(), 2, "exit 2 must block, then allow"); + assert!( + run.some_request_contains("run the linter before finishing"), + "stderr must be fed back to the model as the block reason" + ); +} + +#[tokio::test] +#[ignore] +async fn stop_continue_false_overrides_block() { + let run = run_with_stop_hook( + r#"echo '{"decision":"block","reason":"never stop","continue":false,"stopReason":"budget exhausted"}'"#, + ) + .await; + assert_success(&run, "stop force-stop e2e"); + + assert_eq!( + run.invocations(), + 1, + "force-stop must end the turn without re-firing the gate" + ); + assert!( + !run.some_request_contains("never stop"), + "the overridden block reason must not be fed back to the model" + ); +} + +#[tokio::test] +#[ignore] +async fn stop_block_loop_ends_at_continuation_cap() { + let run = + run_with_stop_hook(r#"echo '{"decision":"block","reason":"keep going forever"}'"#).await; + assert_success(&run, "stop cap e2e"); + + assert_eq!( + run.invocations(), + xai_grok_shell::session::MAX_STOP_HOOK_CONTINUATIONS_PER_TURN, + "the gate must stop being consulted at the continuation cap" + ); +} diff --git a/crates/codegen/xai-grok-subagent-resolution/src/overrides.rs b/crates/codegen/xai-grok-subagent-resolution/src/overrides.rs index b611394..b6ceb13 100644 --- a/crates/codegen/xai-grok-subagent-resolution/src/overrides.rs +++ b/crates/codegen/xai-grok-subagent-resolution/src/overrides.rs @@ -222,9 +222,9 @@ mod tests { persona: persona.map(String::from), capability_mode, isolation, - // Harness override is a /goal-only concern; these resolution tests - // exercise model/persona/capability precedence, not the harness. harness_agent_type: None, + completion_output_cap: None, + spawn_depth: None, } } diff --git a/crates/codegen/xai-grok-telemetry/src/events.rs b/crates/codegen/xai-grok-telemetry/src/events.rs index c0e4a02..a82feda 100644 --- a/crates/codegen/xai-grok-telemetry/src/events.rs +++ b/crates/codegen/xai-grok-telemetry/src/events.rs @@ -146,6 +146,7 @@ pub enum Outcome { pub enum HookOutcome { Success, Error, + Blocked, } /// Outcome of one `PreToolUse` gate callback. Only `Denied` blocks the tool; the rest diff --git a/crates/codegen/xai-grok-telemetry/src/external/tests.rs b/crates/codegen/xai-grok-telemetry/src/external/tests.rs index 582ae8e..4362063 100644 --- a/crates/codegen/xai-grok-telemetry/src/external/tests.rs +++ b/crates/codegen/xai-grok-telemetry/src/external/tests.rs @@ -919,8 +919,8 @@ fn redacting_log_exporter_drops_record_with_unknown_key() { // Remote policy: tighten-only // ───────────────────────────────────────────────────────────────────────────── -#[test] -fn remote_force_disable_stops_emission() { +#[tokio::test(flavor = "current_thread")] +async fn remote_force_disable_stops_emission() { let stream = build(gates_off()); super::apply_remote_policy_on( &stream.ext, @@ -935,8 +935,8 @@ fn remote_force_disable_stops_emission() { ); } -#[test] -fn remote_gate_lock_forces_gates_off_and_never_on() { +#[tokio::test(flavor = "current_thread")] +async fn remote_gate_lock_forces_gates_off_and_never_on() { let stream = build(gates_all_on()); super::apply_remote_policy_on( &stream.ext, diff --git a/crates/codegen/xai-grok-test-support/README.md b/crates/codegen/xai-grok-test-support/README.md index 6bd1ef5..3810872 100644 --- a/crates/codegen/xai-grok-test-support/README.md +++ b/crates/codegen/xai-grok-test-support/README.md @@ -22,7 +22,7 @@ test-support surface. | `scripted` | Data-only response bodies (no axum types in the public surface): `SseEvent { event, data }` (`::data`, `::with_event`), `ScriptedBody::{Json, Sse, Raw}` (`Raw` = byte-controllable malformed SSE), `ScriptedResponse { status, headers, body }` (`::sse`, `::json`, `::text`). Prefer request-matched expectations for inference calls; `enqueue_response(path, response)` remains a compatibility FIFO per path and is still used for non-inference one-shots such as `/v1/settings`. Scripted SSE honors `set_chunk_delay`; matched JSON, raw, SSE, and even empty SSE bodies all honor per-expectation completion barriers. The compatibility `hold_agent_completions` gate also covers foreground scripted SSE on all three inference endpoints. Validation is eager — bad status/header panics at registration. | | `sse` | The three wire formats as event-list builders: `chat_completion_events` / `responses_api_events` / `messages_api_events(text, model, stop_reason)` (echo-style, whitespace-collapsing) plus byte-exact variants `chat_completion_events_exact` / `responses_api_events_exact` (messages is single-delta, byte-exact by construction). The exact/echo split is load-bearing — see the in-module byte-exactness tests. Also the scripted-scenario builders returning `SseEvent`s (for `ScriptedResponse::sse`): `responses_api_reasoning_only_events(reasoning, model)` — reasoning summary deltas completing with a `reasoning` item but no message/output-text, so the shell collector classifies the turn `EmptyReason::ReasoningOnly` (the model-doomloop trigger); `responses_api_reasoning_and_text_events(reasoning, text, model)` — reasoning deltas then a normal text answer (the ordinary reasoning-model turn); `responses_api_reasoning_then_tool_call_events(reasoning, call_id, name, arguments, model)` + its Chat Completions twin `chat_completions_reasoning_then_tool_call_events(...)` — reasoning deltas then one tool call (the think-then-call turn whose tool call finishes the thought and keeps the turn non-empty); the doom-loop check trio: `responses_api_doom_loop_check_events(triggers, reasoning, model)` — a doomed reasoning-only turn with NAMED `response.doom_loop_check` frames re-sent per cumulative prefix of `triggers` plus the terminal `doom_loop_check.triggers` copy on `response.completed`, `responses_api_doom_loop_terminal_only_events(triggers, reasoning, text, model)` — a normal answer whose terminal response alone carries the field, and `responses_api_with_doom_loop_frame(check_frame_data, reasoning, text, model)` — splices one named check frame with a caller-supplied payload (byte-exact `xai_grok_sampling_types::doom_loop::SAMPLE_CHECK_EVENT_DATA{,_CUMULATIVE}` fixtures or malformed variants) into an ordinary turn. | | `acp_client` | `GrokStdioClient` — drives `grok agent stdio` over real pipes through `agent-client-protocol`: spawn variants (`spawn`, `spawn_with_home`, `spawn_with_home_and_env`, `spawn_with_home_env_and_args`), initialize/authenticate, session create/load, prompt, `*_with_timeout` wrappers, captured text + stderr. `RawStdioClient` — raw-wire sibling for bytes the typed `ClientSideConnection` can never produce (escaped-slash methods `"session\/prompt"`, string UUID ids — the Xcode/Foundation shape): `send_line` writes a line verbatim; `response_for_id` matches the response by exact string id (the match IS the id-echo assertion), skips notifications, auto-refuses agent→client requests with `-32601`, and panics on timeout with skipped-traffic diagnostics (count + last lines; `0 other messages` = true silence). Both spawn through one hermetic `spawn_agent_process` (sandbox env + debug-log kill-list exists once) atop `process::spawn_piped_with_stderr_capture` (crate-internal `process` module: pipes, `kill_on_drop`, stderr drain — also used by `leader::LeaderStdioClient`). | -| `headless` | `run_headless(server, args, cwd)` / `run_headless_with_cmd(cmd)` → `HeadlessResult { status, stdout, stderr, timed_out }` (60s cap), `assert_headless_success`, `assert_no_crashes` (panic/SIGSEGV/linker patterns), `stderr_tail`. | +| `headless` | `run_headless(server, args, cwd)` / `run_headless_with_env(server, args, cwd, env)` (extra env applied after the defaults, so it overrides them) / `run_headless_with_cmd(cmd)` → `HeadlessResult { status, stdout, stderr, timed_out }` (60s cap), `assert_headless_success`, `assert_no_crashes` (panic/SIGSEGV/linker patterns), `stderr_tail`. | | `env` | `grok_binary()` (`GROK_BINARY` env → `CARGO_BIN_EXE` → local debug build of `xai-grok-pager`), `git_workdir()` (temp git repo, forces full libgit2 init), `test_env_cmd_tokio(cmd, mock_url, home)` (sandboxed HOME **and GROK_HOME** — Windows resolves `~` via USERPROFILE, so HOME alone doesn't sandbox — + mock endpoints + telemetry kill-switches). | | `leader` | Unix-only `LeaderStdioClient` (`grok agent --leader stdio`, `env_clear`-hermetic, sandboxed `GROK_LEADER_SOCKET`; `spawn_with_binary` runs an explicit binary for version-skew lanes, per-role resolution via `leader_binary()` / `client_binary()` honoring `GROK_BINARY_LEADER` / `GROK_BINARY_CLIENT`) + lock-file helpers: `leader_lock_path`, `read_leader_pid`, `pid_alive`, `wait_for_live_leader`, `wait_for_new_leader`, `wait_for_replay_notifications`, `leader_log`. | | `uds_proxy` | Unix-only `UdsProxy` — frame-aware (4-byte BE length prefix) man-in-the-middle for leader IPC sockets. `UdsProxy::spawn(proxy_path, upstream_path, FaultPlan)`; `FaultPlan { direction, drop_frame, sever_mid_frame, delay, duplicate_frame }` (1-based frame index, per connection per direction); runtime `FaultHandle::sever_now()` + `forwarded(direction)` counters; frame bodies capped at 64 MiB (leader-transport parity — corrupt lengths error instead of allocating). Zero production changes: point `LeaderClient::connect` / `GROK_LEADER_SOCKET` at the proxy path. | diff --git a/crates/codegen/xai-grok-test-support/src/headless.rs b/crates/codegen/xai-grok-test-support/src/headless.rs index 317c996..1372cef 100644 --- a/crates/codegen/xai-grok-test-support/src/headless.rs +++ b/crates/codegen/xai-grok-test-support/src/headless.rs @@ -27,6 +27,18 @@ pub async fn run_headless( server: &MockInferenceServer, args: &[&str], cwd: &Path, +) -> HeadlessResult { + run_headless_with_env(server, args, cwd, &[]).await +} + +/// Like [`run_headless`], but with extra environment variables applied after the +/// shared defaults so they take precedence — e.g. to re-enable a feature the +/// defaults turn off. +pub async fn run_headless_with_env( + server: &MockInferenceServer, + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], ) -> HeadlessResult { let home = TempDir::new().expect("create temp home"); let mut cmd = tokio::process::Command::new(grok_binary()); @@ -37,6 +49,7 @@ pub async fn run_headless( .stderr(std::process::Stdio::piped()) .kill_on_drop(true); test_env_cmd_tokio(&mut cmd, &server.url(), home.path()); + cmd.envs(env.iter().copied()); run_headless_with_cmd(cmd).await } diff --git a/crates/codegen/xai-grok-test-support/src/lib.rs b/crates/codegen/xai-grok-test-support/src/lib.rs index 0a1d12d..89c1612 100644 --- a/crates/codegen/xai-grok-test-support/src/lib.rs +++ b/crates/codegen/xai-grok-test-support/src/lib.rs @@ -37,7 +37,9 @@ pub use counting_server::spawn_counting_server; pub use env::{EnvGuard, git_workdir, grok_binary}; pub use headless::{ HeadlessResult, assert_headless_success, assert_no_crashes, run_headless, - run_headless_with_cmd, stderr_tail, + run_headless_with_cmd, run_headless_with_env, stderr_tail, }; pub use inference_override::{InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher}; -pub use mock_server::{MockInferenceServer, MockModelEntry, ScriptedResponse, SseEvent}; +pub use mock_server::{ + MockInferenceServer, MockModelEntry, ScriptedResponse, SseEvent, StorageUpload, +}; diff --git a/crates/codegen/xai-grok-tools-api/src/slash_commands.rs b/crates/codegen/xai-grok-tools-api/src/slash_commands.rs index 8bfdb8f..75c85e6 100644 --- a/crates/codegen/xai-grok-tools-api/src/slash_commands.rs +++ b/crates/codegen/xai-grok-tools-api/src/slash_commands.rs @@ -34,11 +34,18 @@ pub fn loop_schedule_instruction(args: &str) -> String { scheduling. Do NOT invent or assume a default interval.\n\n\ ## Action\n\ 1. Call scheduler_create with: interval (the compact string you derived), prompt,\n\ - recurring: true, fire_immediately: true. If the interval is unparseable, the tool\n\ + fire_immediately: true. If the interval is unparseable, the tool\n\ returns an error — fix the interval string rather than guessing.\n\ 2. Confirm: what's scheduled, the cadence, that it auto-expires after 7 days,\n\ and that they can cancel with scheduler_delete (include the job ID).\n\ 3. Do NOT execute the prompt inline. The scheduler will fire it immediately.\n\n\ + ## Changing an existing loop\n\ + Call scheduler_create with its task_id and the fields that change; do not\n\ + delete and recreate. If later work changes what a loop should do, update its\n\ + prompt the same way.\n\n\ + ## One-time delayed work\n\ + Scheduling is recurring-only. For \"do X once in N minutes\", run a background\n\ + terminal command (`sleep && `); its completion notifies you.\n\n\ ## Input\n\ {args}" ) @@ -193,6 +200,18 @@ mod tests { assert!(text.contains("")); assert!(text.contains("ask the user how often")); assert!(!text.contains("10m"), "no host-side default interval"); + assert!( + !text.contains("recurring:"), + "the retired one-shot flag must not be referenced" + ); + assert!( + text.contains("task_id"), + "must teach in-place updates via task_id" + ); + assert!( + text.contains("delete and recreate"), + "must steer away from delete+recreate" + ); } #[test] diff --git a/crates/codegen/xai-grok-tools/src/bridge.rs b/crates/codegen/xai-grok-tools/src/bridge.rs index bdf2a5a..bbd0b67 100644 --- a/crates/codegen/xai-grok-tools/src/bridge.rs +++ b/crates/codegen/xai-grok-tools/src/bridge.rs @@ -543,6 +543,31 @@ impl ToolBridge { } } + /// Snapshot the session's scheduled tasks; empty when no scheduler is + /// registered or the actor has stopped. + pub async fn list_scheduled_tasks( + &self, + ) -> Vec { + use crate::implementations::grok_build::scheduler::types::{ + SchedulerCommand, SchedulerHandle, + }; + let sender = { + let res = self.registry.resources.lock().await; + match res.get::() { + Some(handle) => handle.0.clone(), + None => return Vec::new(), + } + }; + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + if sender + .send(SchedulerCommand::List { reply: reply_tx }) + .is_err() + { + return Vec::new(); + } + reply_rx.await.unwrap_or_default() + } + pub async fn delete_scheduled_task( &self, task_id: &str, diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs index 0522f8b..4e99911 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs @@ -4,15 +4,37 @@ use chrono::Utc; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; +use crate::implementations::grok_build::task::types::{ + SessionIdResource, SubagentEvent, SubagentEventSender, SubagentQueryRequest, SubagentRequest, + SubagentRuntimeOverrides, SubagentSnapshotStatus, +}; use crate::notification::types::ToolNotificationHandle; use crate::notification::{ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved}; +use crate::reminders::format_loop_iteration_prompt; use crate::types::resources::{SharedResources, State}; use super::interval::interval_to_human; -use super::types::{ScheduledTask, SchedulerCommand, SchedulerError, SchedulerState}; +use super::types::{ + LOOP_COMPLETION_OUTPUT_CAP, LOOP_FRESH_CHAIN_EVERY, ScheduledTask, SchedulerCommand, + SchedulerError, SchedulerState, +}; const MAX_SCHEDULED_TASKS: usize = 50; +enum LoopFireOutcome { + Spawned(String), + Foreground, + Skipped, +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let head: String = s.chars().take(max_chars).collect(); + format!("{head}\u{2026}") +} + /// Build a `ScheduledTaskCreated` payload from a task. Shared between the /// live `SchedulerCommand::Create` path and the post-restore re-announce so /// the wire format stays in lockstep. @@ -34,7 +56,7 @@ pub struct SchedulerActor { impl SchedulerActor { pub async fn run(mut self) { - self.handle_missed_tasks().await; + self.await_subagent_wiring_for_due_tasks().await; self.announce_existing_tasks().await; loop { @@ -71,6 +93,37 @@ impl SchedulerActor { } } + async fn await_subagent_wiring_for_due_tasks(&self) { + const STARTUP_WIRING_GRACE: Duration = Duration::from_secs(10); + const POLL_INTERVAL: Duration = Duration::from_millis(250); + let deadline = tokio::time::Instant::now() + STARTUP_WIRING_GRACE; + loop { + let (needs_wiring, wired) = { + let res = self.resources.lock().await; + let enabled = res + .get::() + .is_none_or(|v| v.0); + let soon = Utc::now() + chrono::Duration::seconds(2); + let needs_wiring = enabled + && res.get::>().is_some_and(|s| { + s.tasks + .iter() + .any(|t| t.recurring && !t.foreground && t.next_fire_at() <= soon) + }); + let wired = res.get::().is_some() + && res.get::().is_some(); + (needs_wiring, wired) + }; + if !needs_wiring || wired || tokio::time::Instant::now() >= deadline { + return; + } + tokio::select! { + _ = self.cancel_token.cancelled() => return, + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + } + async fn compute_next_fire_delay(&self) -> Duration { let res = self.resources.lock().await; let scheduler_state = res.get::>(); @@ -107,23 +160,46 @@ impl SchedulerActor { let task_id = task.id.clone(); let prompt = task.prompt.clone(); let human_schedule = interval_to_human(task.interval_secs); + let foreground = task.foreground; + let last_subagent_id = task.last_subagent_id.clone(); + let iterations_since_fresh = task.iterations_since_fresh; + let chain_reset_pending = task.chain_reset_pending; // Advancing `last_fired_at` pushes `next_fire_at` forward by one // interval, so the task is not re-selected until it is due again. // Overlapping prompts are deduped downstream by stable queue-item-id. task.last_fired_at = Some(now); - let next_fire_at = Some(task.next_fire_at().to_rfc3339()); + let next_fire_at = task.recurring.then(|| task.next_fire_at().to_rfc3339()); - let should_remove = if !task.recurring { - true - } else { - task.is_expired(now) - }; + if task.recurring && task.is_expired(now) { + let expired_task_id = task_id; + state.tasks.remove(idx); + drop(res); + tracing::info!(task_id = %expired_task_id, "Scheduled task expired; removing without firing"); + self.notification_handle + .send_scheduled_task_removed(ScheduledTaskRemoved { + task_id: expired_task_id, + }); + return; + } + + let should_remove = !task.recurring; if should_remove { state.tasks.remove(idx); } + let background_enabled = res + .get::() + .is_none_or(|v| v.0); + let spawn_deps = if foreground || should_remove || !background_enabled { + None + } else { + let events = res.get::().cloned(); + let session = res.get::().map(|s| s.0.clone()); + events.zip(session) + }; + // Drop the lock before sending the notification to avoid holding it // across potentially blocking operations. drop(res); @@ -131,18 +207,66 @@ impl SchedulerActor { tracing::info!( task_id = %task_id, schedule = %human_schedule, + background = spawn_deps.is_some(), "Firing scheduled task" ); let removed_task_id = should_remove.then(|| task_id.clone()); - self.notification_handle - .send_scheduled_task_fired(ScheduledTaskFired { - task_id, - prompt, - human_schedule, - next_fire_at, - }); + let outcome = match spawn_deps { + Some((events, parent_session_id)) => { + self.fire_as_loop_subagent( + &events, + parent_session_id, + &task_id, + &prompt, + &human_schedule, + last_subagent_id, + iterations_since_fresh, + chain_reset_pending, + ) + .await + } + None => LoopFireOutcome::Foreground, + }; + + match outcome { + LoopFireOutcome::Skipped => { + // No fire happened, but last_fired_at advanced: re-announce so + // the pager's countdown tracks the new next_fire_at instead of + // showing the task as overdue until the next real fire. + let payload = { + let res = self.resources.lock().await; + res.get::>() + .and_then(|s| s.tasks.iter().find(|t| t.id == task_id)) + .map(task_created_payload) + }; + if let Some(payload) = payload { + self.notification_handle + .send_scheduled_task_created(payload); + } + } + LoopFireOutcome::Foreground => { + self.notification_handle + .send_scheduled_task_fired(ScheduledTaskFired { + task_id, + prompt, + human_schedule, + next_fire_at, + subagent_id: None, + }); + } + LoopFireOutcome::Spawned(id) => { + self.notification_handle + .send_scheduled_task_fired(ScheduledTaskFired { + task_id, + prompt, + human_schedule, + next_fire_at, + subagent_id: Some(id), + }); + } + } if let Some(task_id) = removed_task_id { self.notification_handle @@ -150,52 +274,187 @@ impl SchedulerActor { } } - async fn handle_missed_tasks(&mut self) { - let now = Utc::now(); - let mut res = self.resources.lock().await; - let state = res.get_or_default::>(); + #[allow(clippy::too_many_arguments)] + async fn fire_as_loop_subagent( + &self, + events: &SubagentEventSender, + parent_session_id: String, + task_id: &str, + prompt: &str, + human_schedule: &str, + last_subagent_id: Option, + iterations_since_fresh: u32, + chain_reset_pending: bool, + ) -> LoopFireOutcome { + let prev_snapshot = match &last_subagent_id { + Some(prev_id) => { + let (respond_to, rx) = tokio::sync::oneshot::channel(); + let query_sent = events + .0 + .send(SubagentEvent::Query(SubagentQueryRequest { + subagent_id: prev_id.clone(), + block: false, + timeout_ms: None, + respond_to, + })) + .is_ok(); + if query_sent { + tokio::select! { + biased; + _ = self.cancel_token.cancelled() => return LoopFireOutcome::Skipped, + snapshot = rx => match snapshot { + Ok(s) => s, + Err(_) => return LoopFireOutcome::Skipped, + }, + _ = tokio::time::sleep(Duration::from_secs(10)) => { + tracing::warn!( + task_id = %task_id, + previous_subagent = %prev_id, + "Loop in-flight query timed out; skipping this fire" + ); + return LoopFireOutcome::Skipped; + } + } + } else { + // Coordinator channel closed — cannot verify whether the + // previous iteration is still running. Skip rather than + // treating this as "no previous snapshot", which could + // fall through to Foreground inject and double-execute. + tracing::warn!( + task_id = %task_id, + previous_subagent = %prev_id, + "Loop in-flight query send failed (coordinator unavailable); skipping this fire" + ); + return LoopFireOutcome::Skipped; + } + } + None => None, + }; - let missed: Vec<(String, String, u64)> = state - .tasks - .iter() - .filter(|t| t.is_missed(now)) - .map(|t| (t.id.clone(), t.prompt.clone(), t.interval_secs)) - .collect(); - - if missed.is_empty() { - return; + if let Some(snapshot) = &prev_snapshot + && matches!( + snapshot.status, + SubagentSnapshotStatus::Initializing | SubagentSnapshotStatus::Running { .. } + ) + { + tracing::info!( + task_id = %task_id, + previous_subagent = %snapshot.subagent_id, + "Skipping loop fire: previous iteration still running" + ); + return LoopFireOutcome::Skipped; } - drop(res); + let prev_completed_output = prev_snapshot.as_ref().and_then(|s| match &s.status { + SubagentSnapshotStatus::Completed { output, .. } => Some(output.clone()), + _ => None, + }); + let chain_due_for_restart = iterations_since_fresh >= LOOP_FRESH_CHAIN_EVERY; + let anchor_usable = prev_completed_output.is_some() && last_subagent_id.is_some(); + let (resume_from, prior_summary, next_iterations_since_fresh) = if chain_reset_pending { + (None, None, 1) + } else if chain_due_for_restart || !anchor_usable { + let summary = prev_completed_output + .as_deref() + .map(|o| truncate_chars(o, 600)); + (None, summary, 1) + } else { + (last_subagent_id.clone(), None, iterations_since_fresh + 1) + }; - let mut fired_ids = Vec::new(); - for (task_id, prompt, interval_secs) in missed { - tracing::info!(task_id = %task_id, "Firing missed one-shot task"); - self.notification_handle - .send_scheduled_task_fired(ScheduledTaskFired { - task_id: task_id.clone(), - prompt, - human_schedule: interval_to_human(interval_secs), - next_fire_at: None, - }); - fired_ids.push(task_id); + let subagent_id = uuid::Uuid::now_v7().to_string(); + let framed_prompt = + format_loop_iteration_prompt(prompt, task_id, human_schedule, prior_summary.as_deref()); + let description = format!( + "loop: {} ({human_schedule})", + truncate_chars(prompt.lines().next().unwrap_or(prompt), 60) + ); + + { + let mut res = self.resources.lock().await; + let state = res.get_or_default::>(); + let Some(task) = state.tasks.iter_mut().find(|t| t.id == task_id) else { + tracing::info!(task_id = %task_id, "Loop task deleted mid-fire; not spawning"); + return LoopFireOutcome::Skipped; + }; + task.last_subagent_id = Some(subagent_id.clone()); + task.iterations_since_fresh = next_iterations_since_fresh; + task.chain_reset_pending = false; } - let mut res = self.resources.lock().await; - let state = res.get_or_default::>(); - state.tasks.retain(|t| !fired_ids.contains(&t.id)); - drop(res); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + let request = SubagentRequest { + id: subagent_id.clone(), + prompt: framed_prompt, + description, + subagent_type: "general-purpose".to_string(), + parent_session_id, + parent_prompt_id: None, + resume_from, + cwd: None, + runtime_overrides: SubagentRuntimeOverrides { + completion_output_cap: Some(LOOP_COMPLETION_OUTPUT_CAP), + spawn_depth: Some(0), + ..Default::default() + }, + run_in_background: true, + surface_completion: true, + fork_context: false, + result_tx, + }; - for task_id in fired_ids { - self.notification_handle - .send_scheduled_task_removed(ScheduledTaskRemoved { task_id }); + if events + .0 + .send(SubagentEvent::Spawn(Box::new(request))) + .is_err() + { + let mut res = self.resources.lock().await; + let state = res.get_or_default::>(); + if let Some(task) = state.tasks.iter_mut().find(|t| t.id == task_id) { + // Restore the full pre-fire snapshot, including a pending + // chain reset the aborted spawn had consumed — losing it + // would let a later fire resume the old chain under a new + // prompt. + task.last_subagent_id = last_subagent_id; + task.iterations_since_fresh = iterations_since_fresh; + task.chain_reset_pending = chain_reset_pending; + } + return LoopFireOutcome::Foreground; } + + let resources = self.resources.clone(); + let guard_task_id = task_id.to_string(); + let spawned_id = subagent_id.clone(); + tokio::spawn(async move { + let Ok(result) = result_rx.await else { + return; + }; + if result.error.is_none() { + return; + } + tracing::warn!( + task_id = %guard_task_id, + subagent_id = %spawned_id, + error = ?result.error, + "Loop iteration spawn failed; clearing chain anchor" + ); + let mut res = resources.lock().await; + let state = res.get_or_default::>(); + if let Some(task) = state + .tasks + .iter_mut() + .find(|t| t.last_subagent_id.as_deref() == Some(spawned_id.as_str())) + { + task.last_subagent_id = None; + task.iterations_since_fresh = 0; + } + }); + + LoopFireOutcome::Spawned(subagent_id) } /// Re-emit `ScheduledTaskCreated` for every task currently in state. /// - /// Runs after `handle_missed_tasks()` so missed one-shots that were just - /// pruned are not announced as ghost entries. The notification bridge /// forwards each one to the client exactly like a fresh `Create`, which /// lets the pager rebuild its `scheduled_tasks` view after a session /// restore (only `session/update` payloads land in `updates.jsonl`, so the @@ -209,12 +468,18 @@ impl SchedulerActor { if state.tasks.is_empty() { return; } + let now = Utc::now(); // Iteration order matters: the pager sorts the tasks pane by // created_at, but every re-announced task gets a synthetic // `Instant::now()` on the pager side, so the relative order is // determined by the order we send notifications here. Keep // state.tasks as a Vec so insertion order survives. - state.tasks.iter().map(task_created_payload).collect() + state + .tasks + .iter() + .filter(|t| t.recurring || t.next_fire_at() > now) + .map(task_created_payload) + .collect() }; tracing::info!( @@ -242,6 +507,42 @@ impl SchedulerActor { state.tasks.push(task.clone()); let _ = reply.send(Ok(task)); } + SchedulerCommand::Update { + id, + prompt, + interval_secs, + reply, + } => { + let mut res = self.resources.lock().await; + let state = res.get_or_default::>(); + let Some(task) = state.tasks.iter_mut().find(|t| t.id == id) else { + let _ = reply.send(Err(SchedulerError::TaskNotFound(id))); + return; + }; + if let Some(prompt) = prompt { + // A new prompt is a new job: the next fire starts a fresh + // transcript. The anchor is kept (not cleared) so the + // in-flight guard still sees a running old iteration — + // clearing it here would let the next fire double-spawn. + if prompt != task.prompt { + task.chain_reset_pending = true; + task.iterations_since_fresh = 0; + } + task.prompt = prompt; + } + if let Some(interval_secs) = interval_secs { + task.interval_secs = interval_secs; + if task.next_fire_at() <= Utc::now() { + task.last_fired_at = Some(Utc::now()); + } + } + let updated = task.clone(); + drop(res); + + self.notification_handle + .send_scheduled_task_created(task_created_payload(&updated)); + let _ = reply.send(Ok(updated)); + } SchedulerCommand::Delete { id, reply } => { let mut res = self.resources.lock().await; let state = res.get_or_default::>(); @@ -562,51 +863,228 @@ mod tests { } #[tokio::test] - async fn missed_one_shots_all_fire_and_are_removed() { + async fn legacy_one_shot_fires_via_normal_loop_and_is_removed() { let mut resources = Resources::new(); resources.register_state::(); let state = resources.get_or_default::>(); - let past = chrono::Utc::now() - chrono::Duration::seconds(60); - for i in 0..3 { - let mut task = ScheduledTask::new(1, format!("missed-{i}"), false, false); - task.id = format!("missed-{i}"); - task.created_at = past; - state.tasks.push(task); - } + let mut task = ScheduledTask::new(1, "legacy one-shot".into(), false, false); + task.id = "legacy-1".to_string(); + task.created_at = chrono::Utc::now() - chrono::Duration::seconds(60); + state.tasks.push(task); let shared = Arc::new(Mutex::new(resources)); let (notif_handle, mut notif_rx) = ToolNotificationHandle::channel(); let (_cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let cancel_token = CancellationToken::new(); - let mut actor = SchedulerActor { + let actor = SchedulerActor { resources: shared.clone(), notification_handle: notif_handle, cmd_rx, cancel_token: cancel_token.clone(), }; + tokio::spawn(actor.run()); - actor.handle_missed_tasks().await; - - // All three missed one-shots fire (fires first, then removes). - for _ in 0..3 { - let notif = notif_rx.try_recv().expect("missed task should fire"); - assert!(matches!(notif, ToolNotification::ScheduledTaskFired(_))); + let mut kinds = Vec::new(); + for _ in 0..2 { + let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()) + .await + .expect("notification") + .expect("channel open"); + kinds.push(match notif { + ToolNotification::ScheduledTaskCreated(_) => "created", + ToolNotification::ScheduledTaskFired(_) => "fired", + ToolNotification::ScheduledTaskRemoved(_) => "removed", + _ => "other", + }); } - for _ in 0..3 { - let notif = notif_rx.try_recv().expect("missed task should be removed"); - assert!(matches!(notif, ToolNotification::ScheduledTaskRemoved(_))); - } - assert!(notif_rx.try_recv().is_err()); + assert_eq!(kinds, vec!["fired", "removed"]); - // All fired missed one-shots are pruned from state. let res = shared.lock().await; let remaining = res .get::>() .map(|s| s.tasks.len()) .unwrap_or(0); - assert_eq!(remaining, 0, "all fired missed one-shots should be removed"); + assert_eq!(remaining, 0, "legacy one-shot removed after firing"); + cancel_token.cancel(); + } + + #[tokio::test] + async fn update_patches_in_place_preserving_identity_and_phase() { + let (handle, cancel, _notif_rx) = make_test_actor(); + + let task = ScheduledTask::new(300, "check deploy".into(), true, false); + let task_id = task.id.clone(); + let created_at = task.created_at; + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Create { + task, + reply: reply_tx, + }) + .unwrap(); + reply_rx.await.unwrap().unwrap(); + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id.clone(), + prompt: None, + interval_secs: Some(600), + reply: up_tx, + }) + .unwrap(); + let updated = up_rx.await.unwrap().unwrap(); + assert_eq!(updated.id, task_id, "identity preserved"); + assert_eq!(updated.interval_secs, 600); + assert_eq!(updated.prompt, "check deploy"); + assert_eq!(updated.created_at, created_at, "phase anchor preserved"); + assert!(updated.last_fired_at.is_none()); + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id.clone(), + prompt: Some("check rollback".into()), + interval_secs: None, + reply: up_tx, + }) + .unwrap(); + let updated = up_rx.await.unwrap().unwrap(); + assert_eq!(updated.interval_secs, 600); + assert_eq!(updated.prompt, "check rollback"); + + let (list_tx, list_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::List { reply: list_tx }) + .unwrap(); + assert_eq!(list_rx.await.unwrap().len(), 1); + + cancel.cancel(); + } + + #[tokio::test] + async fn update_shrinking_interval_never_fires_immediately() { + let (handle, cancel, mut notif_rx) = make_test_actor(); + + let mut task = ScheduledTask::new(3600, "check deploy".into(), true, false); + task.created_at = chrono::Utc::now() - chrono::Duration::seconds(600); + let task_id = task.id.clone(); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Create { + task, + reply: reply_tx, + }) + .unwrap(); + reply_rx.await.unwrap().unwrap(); + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id, + prompt: None, + interval_secs: Some(60), + reply: up_tx, + }) + .unwrap(); + let updated = up_rx.await.unwrap().unwrap(); + assert!( + updated.next_fire_at() > chrono::Utc::now(), + "an update must never leave the task immediately due" + ); + + let deadline = tokio::time::Instant::now() + Duration::from_millis(300); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(100), notif_rx.recv()).await { + Ok(Some(ToolNotification::ScheduledTaskFired(_))) => { + panic!("update must not trigger a fire"); + } + Ok(Some(_)) => {} + _ => break, + } + } + + cancel.cancel(); + } + + #[tokio::test] + async fn update_unknown_id_errors_and_never_creates() { + let (handle, cancel, _notif_rx) = make_test_actor(); + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: "nonexistent".into(), + prompt: Some("new prompt".into()), + interval_secs: Some(300), + reply: up_tx, + }) + .unwrap(); + let result = up_rx.await.unwrap(); + assert!(matches!(result, Err(SchedulerError::TaskNotFound(_)))); + + let (list_tx, list_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::List { reply: list_tx }) + .unwrap(); + assert!( + list_rx.await.unwrap().is_empty(), + "a failed update must not fall back to creating a task" + ); + + cancel.cancel(); + } + + #[tokio::test] + async fn update_emits_created_notification_as_upsert() { + let (handle, cancel, mut notif_rx) = make_test_actor(); + + let task = ScheduledTask::new(300, "check deploy".into(), true, false); + let task_id = task.id.clone(); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Create { + task, + reply: reply_tx, + }) + .unwrap(); + reply_rx.await.unwrap().unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()).await; + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id.clone(), + prompt: Some("check rollback".into()), + interval_secs: None, + reply: up_tx, + }) + .unwrap(); + up_rx.await.unwrap().unwrap(); + + let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()) + .await + .expect("upsert notification") + .expect("channel open"); + let ToolNotification::ScheduledTaskCreated(created) = notif else { + panic!("expected ScheduledTaskCreated upsert, got {notif:?}"); + }; + assert_eq!(created.task_id, task_id, "same chip identity"); + assert_eq!(created.prompt, "check rollback"); + + cancel.cancel(); } #[tokio::test] @@ -654,6 +1132,524 @@ mod tests { assert_eq!(removed_ids, vec!["cancel-A", "cancel-B"]); } + fn make_test_actor_with_subagents() -> ( + SchedulerHandle, + CancellationToken, + mpsc::UnboundedReceiver, + mpsc::UnboundedReceiver, + ) { + let mut resources = Resources::new(); + resources.register_state::(); + let (subagent_tx, subagent_rx) = mpsc::unbounded_channel(); + resources.insert(SubagentEventSender(subagent_tx)); + resources.insert( + crate::implementations::grok_build::task::types::SessionIdResource( + "parent-session".to_string(), + ), + ); + let shared = Arc::new(Mutex::new(resources)); + + let (notif_handle, notif_rx) = ToolNotificationHandle::channel(); + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let cancel_token = CancellationToken::new(); + + let actor = SchedulerActor { + resources: shared, + notification_handle: notif_handle, + cmd_rx, + cancel_token: cancel_token.clone(), + }; + tokio::spawn(actor.run()); + + (SchedulerHandle(cmd_tx), cancel_token, notif_rx, subagent_rx) + } + + async fn create_due_task(handle: &SchedulerHandle, prompt: &str, foreground: bool) -> String { + let mut task = ScheduledTask::new(1, prompt.into(), true, false); + task.foreground = foreground; + task.created_at = chrono::Utc::now() - chrono::Duration::seconds(10); + let task_id = task.id.clone(); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Create { + task, + reply: reply_tx, + }) + .unwrap(); + reply_rx.await.unwrap().unwrap(); + task_id + } + + async fn next_subagent_event(rx: &mut mpsc::UnboundedReceiver) -> SubagentEvent { + tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("subagent event within timeout") + .expect("subagent channel open") + } + + #[tokio::test] + async fn background_fire_spawns_loop_subagent() { + let (handle, cancel, mut notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + let task_id = create_due_task(&handle, "check deploy status", false).await; + + let event = next_subagent_event(&mut subagent_rx).await; + let SubagentEvent::Spawn(request) = event else { + panic!("expected Spawn, got a different event"); + }; + assert!(request.run_in_background); + assert!(request.surface_completion); + assert!(request.parent_prompt_id.is_none()); + assert!(request.resume_from.is_none(), "first iteration is fresh"); + assert_eq!(request.subagent_type, "general-purpose"); + assert_eq!(request.parent_session_id, "parent-session"); + assert!(request.prompt.contains("check deploy status")); + assert!(request.prompt.contains(&task_id)); + assert!(request.prompt.contains("short status")); + assert_eq!( + request.runtime_overrides.completion_output_cap, + Some(LOOP_COMPLETION_OUTPUT_CAP) + ); + assert_eq!( + request.runtime_overrides.spawn_depth, + Some(0), + "iterations spawn at root depth so they can spawn subagents" + ); + assert!(request.description.starts_with("loop: ")); + + let mut fired_subagent_id = None; + for _ in 0..4 { + let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()) + .await + .expect("notification") + .expect("channel open"); + if let ToolNotification::ScheduledTaskFired(f) = notif { + fired_subagent_id = f.subagent_id; + break; + } + } + assert_eq!(fired_subagent_id.as_deref(), Some(request.id.as_str())); + + let (list_tx, list_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::List { reply: list_tx }) + .unwrap(); + let tasks = list_rx.await.unwrap(); + assert_eq!( + tasks[0].last_subagent_id.as_deref(), + Some(request.id.as_str()) + ); + assert_eq!(tasks[0].iterations_since_fresh, 1); + + cancel.cancel(); + } + + #[tokio::test] + async fn in_flight_iteration_skips_fire_then_resumes_chain() { + let (handle, cancel, mut notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + create_due_task(&handle, "watch ci", false).await; + + let SubagentEvent::Spawn(first) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected first Spawn"); + }; + let first_id = first.id.clone(); + loop { + let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()) + .await + .expect("first-fire notification") + .expect("channel open"); + if matches!(notif, ToolNotification::ScheduledTaskFired(_)) { + break; + } + } + + let SubagentEvent::Query(query) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected Query before second spawn"); + }; + assert_eq!(query.subagent_id, first_id); + assert!(!query.block); + let _ = query.respond_to.send(Some( + crate::implementations::grok_build::task::types::SubagentSnapshot { + subagent_id: first_id.clone(), + description: "loop: watch ci".into(), + subagent_type: "general-purpose".into(), + status: SubagentSnapshotStatus::Running { + turn_count: 1, + tool_call_count: 2, + tokens_used: 0, + context_window_tokens: 0, + context_usage_pct: 0, + tools_used: vec![], + error_count: 0, + }, + started_at_epoch_ms: 0, + duration_ms: 100, + persona: None, + }, + )); + + let SubagentEvent::Query(query2) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected the next tick's Query, not a Spawn"); + }; + match notif_rx.try_recv() { + Ok(ToolNotification::ScheduledTaskCreated(c)) => { + assert!( + c.next_fire_at.is_some(), + "skip re-announce refreshes the countdown" + ); + } + Ok(other) => panic!("skipped fire must only re-announce, got {other:?}"), + Err(_) => panic!("skipped fire should re-announce the task"), + } + assert!( + notif_rx.try_recv().is_err(), + "skipped fire must not emit a fired notification" + ); + assert_eq!(query2.subagent_id, first_id); + let _ = query2.respond_to.send(Some( + crate::implementations::grok_build::task::types::SubagentSnapshot { + subagent_id: first_id.clone(), + description: "loop: watch ci".into(), + subagent_type: "general-purpose".into(), + status: SubagentSnapshotStatus::Completed { + output: "ci green".into(), + tool_calls: 3, + turns: 1, + worktree_path: None, + }, + started_at_epoch_ms: 0, + duration_ms: 100, + persona: None, + }, + )); + + let SubagentEvent::Spawn(second) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected second Spawn after completion"); + }; + assert_eq!(second.resume_from.as_deref(), Some(first_id.as_str())); + assert_ne!(second.id, first_id); + + cancel.cancel(); + } + + #[tokio::test] + async fn prompt_update_resets_chain_interval_update_keeps_it() { + let (handle, cancel, _notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + let task_id = create_due_task(&handle, "watch ci", false).await; + + let SubagentEvent::Spawn(first) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected first Spawn"); + }; + + // Interval-only patch: the chain anchor survives. + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id.clone(), + prompt: None, + interval_secs: Some(3600), + reply: up_tx, + }) + .unwrap(); + let updated = up_rx.await.unwrap().unwrap(); + assert_eq!(updated.last_subagent_id.as_deref(), Some(first.id.as_str())); + assert_eq!(updated.iterations_since_fresh, 1); + + // Prompt patch: new job, fresh chain pending — but the anchor is kept + // so the in-flight guard still covers a running old iteration. + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id, + prompt: Some("watch deploys instead".into()), + interval_secs: None, + reply: up_tx, + }) + .unwrap(); + let updated = up_rx.await.unwrap().unwrap(); + assert_eq!(updated.last_subagent_id.as_deref(), Some(first.id.as_str())); + assert!(updated.chain_reset_pending); + assert_eq!(updated.iterations_since_fresh, 0); + + cancel.cancel(); + } + + #[tokio::test] + async fn prompt_update_keeps_in_flight_guard_then_spawns_fresh() { + let (handle, cancel, _notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + let task_id = create_due_task(&handle, "watch ci", false).await; + + let SubagentEvent::Spawn(first) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected first Spawn"); + }; + let first_id = first.id.clone(); + + // Patch the prompt while iteration 1 is still running. + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id, + prompt: Some("watch deploys instead".into()), + interval_secs: None, + reply: up_tx, + }) + .unwrap(); + up_rx.await.unwrap().unwrap(); + + // Next tick still queries the old iteration; Running must skip. + let SubagentEvent::Query(q1) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected Query after prompt update"); + }; + assert_eq!(q1.subagent_id, first_id, "guard queries the kept anchor"); + let _ = q1.respond_to.send(Some( + crate::implementations::grok_build::task::types::SubagentSnapshot { + subagent_id: first_id.clone(), + description: "loop: watch ci".into(), + subagent_type: "general-purpose".into(), + status: SubagentSnapshotStatus::Running { + turn_count: 1, + tool_call_count: 1, + tokens_used: 0, + context_window_tokens: 0, + context_usage_pct: 0, + tools_used: vec![], + error_count: 0, + }, + started_at_epoch_ms: 0, + duration_ms: 100, + persona: None, + }, + )); + + // Old iteration done: the following tick spawns the NEW job fresh — + // no resume_from, and the framing carries no old-task output. + let SubagentEvent::Query(q2) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected second Query, not a Spawn"); + }; + let _ = q2.respond_to.send(Some( + crate::implementations::grok_build::task::types::SubagentSnapshot { + subagent_id: first_id.clone(), + description: "loop: watch ci".into(), + subagent_type: "general-purpose".into(), + status: SubagentSnapshotStatus::Completed { + output: "old task output".into(), + tool_calls: 1, + turns: 1, + worktree_path: None, + }, + started_at_epoch_ms: 0, + duration_ms: 100, + persona: None, + }, + )); + let SubagentEvent::Spawn(second) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected fresh Spawn after old iteration completed"); + }; + assert!(second.resume_from.is_none(), "prompt change spawns fresh"); + assert!(second.prompt.contains("watch deploys instead")); + assert!( + !second.prompt.contains("old task output"), + "old task's output must not leak into the new job" + ); + + cancel.cancel(); + } + + #[tokio::test] + async fn failed_spawn_resets_chain_state() { + let (handle, cancel, _notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + // One-hour interval, backdated to fire exactly once: the assertion + // window cannot be raced by a second fire re-pointing the anchor. + let mut task = ScheduledTask::new(3600, "watch ci".into(), true, false); + task.created_at = chrono::Utc::now() - chrono::Duration::seconds(3610); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Create { + task, + reply: reply_tx, + }) + .unwrap(); + reply_rx.await.unwrap().unwrap(); + + let SubagentEvent::Spawn(request) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected Spawn"); + }; + let spawned_id = request.id.clone(); + let _ = request.result_tx.send( + crate::implementations::grok_build::task::types::SubagentResult { + success: false, + error: Some("worktree creation failed".into()), + subagent_id: spawned_id.clone(), + ..Default::default() + }, + ); + + // The watcher clears the anchor AND the fresh-chain counter so the + // next fire starts clean. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + let (list_tx, list_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::List { reply: list_tx }) + .unwrap(); + let tasks = list_rx.await.unwrap(); + if tasks[0].last_subagent_id.is_none() { + assert_eq!(tasks[0].iterations_since_fresh, 0); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "watcher did not reset chain state" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + cancel.cancel(); + } + + #[tokio::test] + async fn spawn_send_failure_restores_pending_chain_reset() { + let (handle, cancel, _notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + let task_id = create_due_task(&handle, "watch ci", false).await; + + let SubagentEvent::Spawn(first) = next_subagent_event(&mut subagent_rx).await else { + panic!("expected first Spawn"); + }; + let first_id = first.id.clone(); + + let (up_tx, up_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::Update { + id: task_id.clone(), + prompt: Some("watch deploys instead".into()), + interval_secs: None, + reply: up_tx, + }) + .unwrap(); + up_rx.await.unwrap().unwrap(); + + // Coordinator gone: the next fire's guard query and spawn send both + // fail, taking the rollback path. + drop(subagent_rx); + + // Wait for the failed fire's rollback to land in state. + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + loop { + let (list_tx, list_rx) = tokio::sync::oneshot::channel(); + handle + .0 + .send(SchedulerCommand::List { reply: list_tx }) + .unwrap(); + let task = list_rx.await.unwrap().remove(0); + // The rollback restores the pre-fire snapshot exactly: old + // anchor, pre-fire counter, and the still-pending reset. + if task.last_subagent_id.as_deref() == Some(first_id.as_str()) + && task.chain_reset_pending + && task.iterations_since_fresh == 0 + && task.last_fired_at.is_some() + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "rollback did not restore the full snapshot: {task:?}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + cancel.cancel(); + } + + #[tokio::test] + async fn background_loops_disabled_forces_legacy_path() { + let mut resources = Resources::new(); + resources.register_state::(); + let (subagent_tx, mut subagent_rx) = mpsc::unbounded_channel(); + resources.insert(SubagentEventSender(subagent_tx)); + resources.insert( + crate::implementations::grok_build::task::types::SessionIdResource( + "parent-session".to_string(), + ), + ); + resources.insert(crate::types::resources::SchedulerBackgroundLoops(false)); + let shared = Arc::new(Mutex::new(resources)); + + let (notif_handle, mut notif_rx) = ToolNotificationHandle::channel(); + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let cancel_token = CancellationToken::new(); + tokio::spawn( + SchedulerActor { + resources: shared, + notification_handle: notif_handle, + cmd_rx, + cancel_token: cancel_token.clone(), + } + .run(), + ); + let handle = SchedulerHandle(cmd_tx); + create_due_task(&handle, "watch ci", false).await; + + let mut fired = None; + for _ in 0..4 { + let notif = tokio::time::timeout(Duration::from_secs(3), notif_rx.recv()) + .await + .expect("notification") + .expect("channel open"); + if let ToolNotification::ScheduledTaskFired(f) = notif { + fired = Some(f); + break; + } + } + assert!( + fired.expect("fired notification").subagent_id.is_none(), + "disabled config must take the legacy inject path" + ); + assert!( + tokio::time::timeout(Duration::from_millis(200), subagent_rx.recv()) + .await + .is_err(), + "disabled config must not touch the subagent coordinator" + ); + + cancel_token.cancel(); + } + + #[tokio::test] + async fn foreground_task_fires_legacy_inject_path() { + let (handle, cancel, mut notif_rx, mut subagent_rx) = make_test_actor_with_subagents(); + create_due_task(&handle, "needs main context", true).await; + + let mut fired = None; + for _ in 0..4 { + let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv()) + .await + .expect("notification") + .expect("channel open"); + if let ToolNotification::ScheduledTaskFired(f) = notif { + fired = Some(f); + break; + } + } + let fired = fired.expect("fired notification"); + assert!(fired.subagent_id.is_none()); + + assert!( + tokio::time::timeout(Duration::from_millis(200), subagent_rx.recv()) + .await + .is_err(), + "foreground fire must not touch the subagent coordinator" + ); + + cancel.cancel(); + } + #[tokio::test] async fn cancel_with_no_tasks_sends_no_removed() { let mut resources = Resources::new(); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/create.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/create.rs index efc7fbc..01850ac 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/create.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/create.rs @@ -13,22 +13,31 @@ pub use xai_grok_tools_api::slash_commands::{ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct SchedulerCreateInput { - /// Interval string: "5m", "2h", "1d", etc. - #[schemars(description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\"")] - pub interval: String, + #[serde(default)] + #[schemars( + description = "Id of an existing task to update in place: provided fields replace old \ + values, omitted ones are unchanged, the schedule keeps its phase, and an \ + unknown id errors. Omit to create a task." + )] + pub task_id: Option, - /// The prompt to run on each fire. - #[schemars(description = "The prompt text to execute on each scheduled fire")] - pub prompt: String, + #[serde(default)] + #[schemars( + description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\". \ + Required to create; optional with task_id" + )] + pub interval: Option, + + #[serde(default)] + #[schemars(description = "The prompt text to execute on each scheduled fire. \ + Required to create; optional with task_id")] + pub prompt: Option, - /// Whether the task recurs. Default true. #[serde( default = "default_true", deserialize_with = "crate::types::schema::deserialize_lenient_bool" )] - #[schemars( - description = "Whether the task repeats (true) or fires once (false). Default: true" - )] + #[schemars(skip)] pub recurring: bool, /// Whether the task persists across sessions. Default false (session-only). @@ -36,9 +45,23 @@ pub struct SchedulerCreateInput { default, deserialize_with = "crate::types::schema::deserialize_lenient_option_bool" )] - #[schemars(description = "Whether the task persists across sessions. Default: false")] + #[schemars( + description = "Whether the task persists across sessions. Default: false. \ + Create-only: ignored with task_id" + )] pub durable: Option, + #[serde( + default, + deserialize_with = "crate::types::schema::deserialize_lenient_option_bool" + )] + #[schemars( + description = "Run each fire as a main-conversation turn instead of a background \ + subagent; set true only when runs need the conversation's context. \ + Default: false. Create-only: ignored with task_id" + )] + pub foreground: Option, + /// Whether to fire immediately on creation. Default false (wait for the /// first interval — a "scheduled" task should not run on creation unless /// explicitly asked to). @@ -47,7 +70,8 @@ pub struct SchedulerCreateInput { deserialize_with = "crate::types::schema::deserialize_lenient_bool" )] #[schemars( - description = "Whether to fire immediately on creation (true) or wait for the first interval (false). Default: false" + description = "Whether to fire immediately on creation (true) or wait for the first \ + interval (false). Default: false. Create-only: ignored with task_id" )] pub fire_immediately: bool, } @@ -61,7 +85,8 @@ fn default_true() -> bool { pub struct SchedulerCreateOutput { pub id: String, pub human_schedule: String, - pub recurring: bool, + #[serde(default)] + pub updated: bool, } impl xai_tool_runtime::ToolOutput for SchedulerCreateOutput {} @@ -79,23 +104,23 @@ impl crate::types::tool_metadata::ToolMetadata for SchedulerCreateTool { } fn description_template(&self) -> &str { - r#"Create a scheduled task that runs a prompt on a recurring interval. + r#"Create a scheduled task that runs a prompt on a recurring interval, or update an existing one in place. Set fire_immediately: true to also fire once on creation; by default the first run waits for the interval. +To change an existing task, pass its task_id: provided fields replace old values, omitted ones are unchanged, and the schedule keeps its phase. An unknown id errors. + Usage notes: - Interval format: "5m" (minutes), "2h" (hours), "1d" (days), "60s" (seconds, min 60) - Maximum 50 scheduled tasks at once -- Recurring tasks auto-expire after 7 days"# +- Tasks auto-expire after 7 days +- For one-time delayed work, run a background terminal command (e.g. `sleep 1800 && `) instead; its completion notifies you"# // TODO: scheduler tools share ToolKind::Other so they can't be template-ized // via ${{ tools.by_kind.* }}. If tool name randomization is needed, add // dedicated ToolKind variants (SchedulerCreate, SchedulerDelete, SchedulerList). } fn emitted_notifications(&self) -> &'static [&'static str] { - // A create call only registers the task (the actor emits - // ScheduledTaskCreated). Fired/Removed come later from the actor timer, - // delete, or shutdown — not from this tool's execution. &["ScheduledTaskCreated"] } @@ -133,7 +158,7 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool { #[tracing::instrument( name = "tool.scheduler_create", skip_all, - fields(interval = %input.interval) + fields(interval = input.interval.as_deref().unwrap_or(""), task_id = input.task_id.as_deref().unwrap_or("")) )] async fn run( &self, @@ -143,7 +168,11 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; - let interval_secs = parse_interval(&input.interval) + let interval_secs = input + .interval + .as_deref() + .map(parse_interval) + .transpose() .map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?; let sender = { @@ -156,39 +185,91 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool { .clone() }; + let send_and_wait = |cmd: SchedulerCommand, + reply_rx: tokio::sync::oneshot::Receiver< + Result, + >| async move { + sender.send(cmd).map_err(|_| { + xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped") + })?; + reply_rx + .await + .map_err(|_| { + xai_tool_runtime::ToolError::custom( + "process_manager", + "Scheduler actor dropped reply", + ) + })? + .map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string())) + }; + + if let Some(task_id) = input.task_id { + if input.prompt.is_none() && interval_secs.is_none() { + return Err(xai_tool_runtime::ToolError::invalid_arguments( + "nothing to update: provide interval and/or prompt alongside task_id", + )); + } + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let updated = send_and_wait( + SchedulerCommand::Update { + id: task_id, + prompt: input.prompt, + interval_secs, + reply: reply_tx, + }, + reply_rx, + ) + .await?; + + return Ok(SchedulerCreateOutput { + id: updated.id, + human_schedule: interval_to_human(updated.interval_secs), + updated: true, + }); + } + + if !input.recurring { + return Err(xai_tool_runtime::ToolError::invalid_arguments( + "one-shot tasks are not supported; run a background terminal command instead \ + (`sleep && `, background: true) or do the work now", + )); + } + + let interval_secs = interval_secs.ok_or_else(|| { + xai_tool_runtime::ToolError::invalid_arguments( + "interval is required when creating a task", + ) + })?; + let prompt = input.prompt.ok_or_else(|| { + xai_tool_runtime::ToolError::invalid_arguments( + "prompt is required when creating a task", + ) + })?; + let durable = input.durable.unwrap_or(false); - let task = ScheduledTask::with_fire_immediately( + let mut task = ScheduledTask::with_fire_immediately( interval_secs, - input.prompt, - input.recurring, + prompt, + true, durable, input.fire_immediately, ); + task.foreground = input.foreground.unwrap_or(false); let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); - sender - .send(SchedulerCommand::Create { - task: task.clone(), + let created = send_and_wait( + SchedulerCommand::Create { + task, reply: reply_tx, - }) - .map_err(|_| { - xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped") - })?; - - let created = reply_rx - .await - .map_err(|_| { - xai_tool_runtime::ToolError::custom( - "process_manager", - "Scheduler actor dropped reply", - ) - })? - .map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?; + }, + reply_rx, + ) + .await?; Ok(SchedulerCreateOutput { id: created.id, human_schedule: interval_to_human(interval_secs), - recurring: input.recurring, + updated: false, }) } } @@ -196,6 +277,184 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool { #[cfg(test)] mod tests { use super::*; + use crate::implementations::grok_build::scheduler::actor::SchedulerActor; + use crate::notification::types::ToolNotificationHandle; + use crate::types::resources::{Resources, SharedResources, State}; + use crate::types::tool_metadata::test_ctx; + use xai_tool_runtime::Tool; + + fn scheduler_resources() -> (SharedResources, tokio_util::sync::CancellationToken) { + let mut resources = Resources::new(); + resources.register_state::(); + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + resources.insert(SchedulerHandle(cmd_tx)); + let shared = resources.into_shared(); + + let (notif_handle, _notif_rx) = ToolNotificationHandle::channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let actor = SchedulerActor { + resources: shared.clone(), + notification_handle: notif_handle, + cmd_rx, + cancel_token: cancel_token.clone(), + }; + tokio::spawn(actor.run()); + (shared, cancel_token) + } + + fn input(json: serde_json::Value) -> SchedulerCreateInput { + serde_json::from_value(json).expect("valid input json") + } + + async fn task_count(resources: &SharedResources) -> usize { + let res = resources.lock().await; + res.get::>() + .map(|s| s.tasks.len()) + .unwrap_or(0) + } + + #[tokio::test] + async fn create_requires_interval_and_prompt() { + let (resources, cancel) = scheduler_resources(); + + let err = SchedulerCreateTool + .run(test_ctx(resources.clone()), input(serde_json::json!({}))) + .await + .expect_err("create without interval must fail"); + assert!(err.to_string().contains("interval is required")); + + let err = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({"interval": "5m"})), + ) + .await + .expect_err("create without prompt must fail"); + assert!(err.to_string().contains("prompt is required")); + + assert_eq!(task_count(&resources).await, 0); + cancel.cancel(); + } + + #[tokio::test] + async fn recurring_false_errors_with_sleep_guidance() { + let (resources, cancel) = scheduler_resources(); + + let err = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({ + "interval": "5m", "prompt": "check", "recurring": false + })), + ) + .await + .expect_err("one-shot must be rejected"); + assert!(err.to_string().contains("sleep"), "steers to sleep: {err}"); + assert_eq!(task_count(&resources).await, 0); + cancel.cancel(); + } + + #[tokio::test] + async fn update_unknown_task_id_errors_and_never_creates() { + let (resources, cancel) = scheduler_resources(); + + let err = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({ + "task_id": "nonexistent", "prompt": "new prompt" + })), + ) + .await + .expect_err("unknown id must error"); + assert!(err.to_string().contains("no scheduled task with id")); + assert_eq!( + task_count(&resources).await, + 0, + "strict update must not fall back to create" + ); + cancel.cancel(); + } + + #[tokio::test] + async fn update_ignores_legacy_recurring_flag() { + let (resources, cancel) = scheduler_resources(); + + let created = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({"interval": "5m", "prompt": "check deploy"})), + ) + .await + .expect("create succeeds"); + + let updated = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({ + "task_id": created.id, "interval": "10m", "recurring": false + })), + ) + .await + .expect("update succeeds despite legacy flag"); + assert!(updated.updated); + assert_eq!(updated.human_schedule, "every 10 minutes"); + cancel.cancel(); + } + + #[tokio::test] + async fn update_with_no_patch_fields_errors() { + let (resources, cancel) = scheduler_resources(); + + let err = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({"task_id": "abc123"})), + ) + .await + .expect_err("empty patch must error"); + assert!(err.to_string().contains("nothing to update")); + cancel.cancel(); + } + + #[tokio::test] + async fn create_then_update_patches_in_place() { + let (resources, cancel) = scheduler_resources(); + + let created = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({"interval": "5m", "prompt": "check deploy"})), + ) + .await + .expect("create succeeds"); + assert!(!created.updated); + assert_eq!(created.human_schedule, "every 5 minutes"); + + let updated = SchedulerCreateTool + .run( + test_ctx(resources.clone()), + input(serde_json::json!({"task_id": created.id, "interval": "10m"})), + ) + .await + .expect("update succeeds"); + assert!(updated.updated); + assert_eq!(updated.id, created.id, "identity preserved"); + assert_eq!(updated.human_schedule, "every 10 minutes"); + assert_eq!(task_count(&resources).await, 1, "no second task"); + cancel.cancel(); + } + + #[test] + fn schema_hides_recurring_and_advertises_task_id() { + let schema = schemars::schema_for!(SchedulerCreateInput); + let json = serde_json::to_string(&schema).unwrap(); + assert!( + !json.contains("recurring"), + "recurring must not be advertised: {json}" + ); + assert!(json.contains("task_id")); + } #[test] fn loop_usage_message_has_no_host_default() { diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/types.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/types.rs index f2c7440..6b94158 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/types.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/types.rs @@ -9,20 +9,43 @@ pub enum SchedulerError { #[error("maximum of {0} scheduled tasks reached")] TaskLimitReached(usize), + + #[error("no scheduled task with id {0}; call scheduler_list to see active task ids")] + TaskNotFound(String), } -/// A single scheduled recurring or one-shot task. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduledTask { pub id: String, pub interval_secs: u64, pub prompt: String, + #[serde(default = "default_recurring")] pub recurring: bool, pub durable: bool, + #[serde(default)] + pub foreground: bool, pub created_at: DateTime, pub last_fired_at: Option>, pub expires_at: Option>, + #[serde(default)] + pub last_subagent_id: Option, + #[serde(default)] + pub iterations_since_fresh: u32, + /// Set when the prompt is patched: the next fire starts a fresh + /// transcript instead of resuming the old task's. The anchor itself is + /// kept until then so the in-flight guard can still see a running + /// iteration. + #[serde(default)] + pub chain_reset_pending: bool, +} + +pub const LOOP_FRESH_CHAIN_EVERY: u32 = 10; + +pub const LOOP_COMPLETION_OUTPUT_CAP: usize = 4_000; + +fn default_recurring() -> bool { + true } impl ScheduledTask { @@ -51,6 +74,7 @@ impl ScheduledTask { prompt, recurring, durable, + foreground: false, created_at, last_fired_at: None, expires_at: if recurring { @@ -58,6 +82,9 @@ impl ScheduledTask { } else { None }, + last_subagent_id: None, + iterations_since_fresh: 0, + chain_reset_pending: false, } } @@ -71,11 +98,6 @@ impl ScheduledTask { pub fn is_expired(&self, now: DateTime) -> bool { self.expires_at.is_some_and(|exp| now >= exp) } - - /// Whether this task was missed (one-shot: fire time already passed, never fired). - pub fn is_missed(&self, now: DateTime) -> bool { - !self.recurring && self.last_fired_at.is_none() && self.next_fire_at() < now - } } /// Persisted state for the scheduler, stored via Resources + ResourcesPersistence. @@ -97,6 +119,12 @@ pub enum SchedulerCommand { task: ScheduledTask, reply: oneshot::Sender>, }, + Update { + id: String, + prompt: Option, + interval_secs: Option, + reply: oneshot::Sender>, + }, Delete { id: String, reply: oneshot::Sender, @@ -161,25 +189,22 @@ mod tests { } #[test] - fn is_missed_returns_true_for_unfired_one_shot_past_due() { - let mut task = ScheduledTask::new(1, "test".into(), false, false); - task.created_at = Utc::now() - chrono::Duration::seconds(10); - assert!(task.is_missed(Utc::now())); + fn legacy_state_without_recurring_field_deserializes_as_recurring() { + let json = r#"{"id":"abc123","intervalSecs":300,"prompt":"check", + "durable":true,"createdAt":"2026-01-01T00:00:00Z", + "lastFiredAt":null,"expiresAt":null}"#; + let task: ScheduledTask = serde_json::from_str(json).unwrap(); + assert!(task.recurring); } #[test] - fn is_missed_returns_false_for_recurring() { - let mut task = ScheduledTask::new(1, "test".into(), true, false); - task.created_at = Utc::now() - chrono::Duration::seconds(10); - assert!(!task.is_missed(Utc::now())); - } - - #[test] - fn is_missed_returns_false_if_already_fired() { - let mut task = ScheduledTask::new(1, "test".into(), false, false); - task.created_at = Utc::now() - chrono::Duration::seconds(10); - task.last_fired_at = Some(Utc::now()); - assert!(!task.is_missed(Utc::now())); + fn legacy_one_shot_state_still_deserializes() { + let json = r#"{"id":"abc123","intervalSecs":300,"prompt":"check", + "recurring":false,"durable":true, + "createdAt":"2026-01-01T00:00:00Z", + "lastFiredAt":null,"expiresAt":null}"#; + let task: ScheduledTask = serde_json::from_str(json).unwrap(); + assert!(!task.recurring); } #[test] diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs index a38aa05..a23a2ec 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs @@ -313,6 +313,8 @@ impl xai_tool_runtime::Tool for TaskTool { // parent agent decides the flavor (the `/goal` harness override // is set only by the harness-internal role spawners). harness_agent_type: None, + completion_output_cap: None, + spawn_depth: None, }, run_in_background: input.run_in_background, // Model-spawned subagents must still appear in the idle reminder. diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs index 9e2541c..8d0cb06 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs @@ -105,6 +105,8 @@ pub struct SubagentRuntimeOverrides { /// (implementer vs explorer). `None` for every non-goal spawn ⇒ the parent /// agent decides the flavor (unchanged behavior). pub harness_agent_type: Option, + pub completion_output_cap: Option, + pub spawn_depth: Option, } /// Re-export of [`xai_tool_types::is_not_sentinel`] for existing call sites. diff --git a/crates/codegen/xai-grok-tools/src/notification/types.rs b/crates/codegen/xai-grok-tools/src/notification/types.rs index e76bbe6..a75ad02 100644 --- a/crates/codegen/xai-grok-tools/src/notification/types.rs +++ b/crates/codegen/xai-grok-tools/src/notification/types.rs @@ -311,6 +311,7 @@ pub struct ScheduledTaskFired { pub human_schedule: String, /// RFC3339 timestamp of next fire (for live countdown viz). pub next_fire_at: Option, + pub subagent_id: Option, } /// Notification that a scheduled task was removed (deleted, expired, or one-shot completed). diff --git a/crates/codegen/xai-grok-tools/src/reminders/mod.rs b/crates/codegen/xai-grok-tools/src/reminders/mod.rs index ab1c38d..a46e610 100644 --- a/crates/codegen/xai-grok-tools/src/reminders/mod.rs +++ b/crates/codegen/xai-grok-tools/src/reminders/mod.rs @@ -26,7 +26,6 @@ pub use task_completion::TaskCompletionReminder; pub const DEFAULT_REMINDER_TAG: &str = "system-reminder"; /// Wrap plain text in `` tags (default hyphen variant). -/// /// Input: `"Some reminder text"` /// Output: `"\nSome reminder text\n"` pub fn wrap_reminder(text: &str) -> String { @@ -61,6 +60,28 @@ pub fn format_scheduled_task_prompt(prompt: &str, task_id: &str, human_schedule: ) } +pub fn format_loop_iteration_prompt( + prompt: &str, + task_id: &str, + human_schedule: &str, + prior_iteration_summary: Option<&str>, +) -> String { + let prior = prior_iteration_summary + .map(|s| format!("\nYour previous iteration ended with:\n{s}\n")) + .unwrap_or_default(); + format!( + "\n\ + Scheduled task {task_id} ({human_schedule}). Earlier iterations, if any, appear \ + above.\n\ + Run the task below. End with a short status: what changed or needs attention. \ + The status is relayed to the main agent.\n\ + {prior}\ + \n\ + \n\ + {prompt}" + ) +} + /// Append wrapped reminders to a tool output string. /// Returns output unchanged if reminders is empty. /// @@ -135,6 +156,30 @@ mod tests { assert!(out.ends_with("do stuff")); } + #[test] + fn format_loop_iteration_prompt_frames_subagent_iteration() { + let out = format_loop_iteration_prompt("check ci", "task-9", "every 5 minutes", None); + assert!(out.starts_with("")); + assert!(out.contains("task task-9")); + assert!(out.contains("every 5 minutes")); + assert!(out.contains("short status")); + assert!(out.ends_with("check ci")); + assert!( + !out.contains("previous iteration"), + "no prior-output note without a summary" + ); + + let with_prior = format_loop_iteration_prompt( + "check ci", + "task-9", + "every 5 minutes", + Some("ci was green"), + ); + assert!(with_prior.contains("previous iteration")); + assert!(with_prior.contains("ci was green")); + assert!(with_prior.ends_with("check ci")); + } + #[test] fn format_with_reminders_returns_unchanged_when_empty() { let output = "file content here".to_string(); diff --git a/crates/codegen/xai-grok-tools/src/types/output.rs b/crates/codegen/xai-grok-tools/src/types/output.rs index 1f56cb8..197a581 100644 --- a/crates/codegen/xai-grok-tools/src/types/output.rs +++ b/crates/codegen/xai-grok-tools/src/types/output.rs @@ -961,9 +961,10 @@ impl ToolOutput { } } ToolOutput::SchedulerCreate(o) => { + let verb = if o.updated { "updated" } else { "created" }; format!( - "Scheduled task created (ID: {}, {}, recurring: {}).", - o.id, o.human_schedule, o.recurring + "Scheduled task {} (ID: {}, {}).", + verb, o.id, o.human_schedule ) } ToolOutput::SchedulerDelete(o) => o.message.clone(), diff --git a/crates/codegen/xai-grok-tools/src/types/resources.rs b/crates/codegen/xai-grok-tools/src/types/resources.rs index dcd3557..42108b2 100644 --- a/crates/codegen/xai-grok-tools/src/types/resources.rs +++ b/crates/codegen/xai-grok-tools/src/types/resources.rs @@ -652,6 +652,19 @@ impl Default for RespectGitignore { /// Default `false`. Hosts may enable this via remote config or local settings. #[derive(Debug, Clone, Copy, Default)] pub struct PathNotFoundHints(pub bool); +/// Whether scheduled task fires execute in background loop subagents. +/// +/// `false` forces every fire onto the legacy main-conversation path. +/// Configured via `[scheduler] background_loops` in `config.toml`, the +/// `GROK_SCHEDULER_BACKGROUND_LOOPS` env var, or the +/// `scheduler_background_loops` remote setting. +#[derive(Debug, Clone, Copy)] +pub struct SchedulerBackgroundLoops(pub bool); +impl Default for SchedulerBackgroundLoops { + fn default() -> Self { + Self(true) + } +} /// Map of canonical tool names → model-facing tool names. #[derive(Debug, Clone, Default)] pub struct ToolNameMapping(pub HashMap); diff --git a/crates/codegen/xai-grok-version/Cargo.toml b/crates/codegen/xai-grok-version/Cargo.toml index be1e940..ad7d430 100644 --- a/crates/codegen/xai-grok-version/Cargo.toml +++ b/crates/codegen/xai-grok-version/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-version" -version = "0.2.105" +version = "0.2.106" edition.workspace = true description = "Lockstepped grok CLI version." diff --git a/crates/codegen/xai-grok-workspace/src/handle.rs b/crates/codegen/xai-grok-workspace/src/handle.rs index b844da1..e066a34 100644 --- a/crates/codegen/xai-grok-workspace/src/handle.rs +++ b/crates/codegen/xai-grok-workspace/src/handle.rs @@ -7409,7 +7409,7 @@ pub(crate) mod tests { let spec = xai_grok_hooks::config::HookSpec { name: "injected".into(), event: xai_grok_hooks::event::HookEventName::SessionStart, - handler_type: "command".into(), + handler_type: xai_grok_hooks::config::HandlerType::Command, configured_matcher: None, matcher: None, enabled: true, diff --git a/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs b/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs index d413832..24ce593 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs @@ -1,8 +1,7 @@ //! Auto permission mode: LLM transcript classifier with safe fast-paths. //! //! Port of common agent auto-permission classifier semantics adapted to Grok's -//! `AccessKind` permission gate (classifier blocks prompt the user; upstream -//! denial-limit tracking is intentionally not ported). +//! `AccessKind` permission gate. use std::future::Future; use std::pin::Pin; @@ -24,12 +23,25 @@ use super::types::AccessKind; pub enum ClassifierVerdict { /// Safe to run without user prompt. Allow, - /// Blocked by classifier; the user is prompted to decide. Block, - /// Classifier unavailable (API error / no client); treated as a block (prompt). Unavailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClassifierOutcome { + pub verdict: ClassifierVerdict, + pub reason: Option, +} + +impl From for ClassifierOutcome { + fn from(verdict: ClassifierVerdict) -> Self { + Self { + verdict, + reason: None, + } + } +} + /// Role of a single classifier request message (transport-agnostic; the shell /// crate maps these onto sampling-types so this crate stays decoupled). #[derive(Debug, Clone, PartialEq, Eq)] @@ -138,7 +150,7 @@ pub trait PermissionClassifier: Send + Sync { access: &'a AccessKind, access_detail: Option<&'a str>, context: ClassifierContext, - ) -> Pin + Send + 'a>>; + ) -> Pin + Send + 'a>>; } /// Fixed-verdict classifier for tests and headless fallbacks. @@ -152,16 +164,14 @@ impl PermissionClassifier for FixedClassifier { _access: &'a AccessKind, _access_detail: Option<&'a str>, _context: ClassifierContext, - ) -> Pin + Send + 'a>> { + ) -> Pin + Send + 'a>> { let v = self.0; - Box::pin(async move { v }) + Box::pin(async move { v.into() }) } } /// Production default classifier: rule-based transcript-style risk assessment /// without a network call. Blocks known-dangerous patterns; allows routine -/// dev commands; **unknown bash defaults to Block** (which prompts the user) -/// so auto is not silent always-approve. A live LLM can /// replace this via `set_classifier` and use full transcript context. #[derive(Debug, Default, Clone, Copy)] pub struct HeuristicPermissionClassifier; @@ -410,7 +420,7 @@ fn classify_bash(cmd: &str) -> ClassifierVerdict { // (or any `env` option) can change which binary runs / how code resolves. // Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't // hide the key. - if script_sets_unsafe_env(tree.root_node(), cmd, &cmds) { + if script_env_risk(tree.root_node(), cmd, &cmds) != EnvRisk::Safe { return ClassifierVerdict::Block; } // A routine command can still write an arbitrary destination via a redirect @@ -559,7 +569,7 @@ fn package_manager_subcommand_is_routine(prog: &str, inner: &[String]) -> Option LaunchTarget::Unresolved => return Some(false), LaunchTarget::Inner(launched) => { return Some( - !command_env_is_unsafe(launched) + command_env_risk(launched) == EnvRisk::Safe && !launched_writes_nonsink(launched) && bash_command_is_routine(launched), ); @@ -742,17 +752,52 @@ fn explicit_launch_target<'a>(head: &str, inner: &'a [String]) -> LaunchTarget<' } } -/// Default-deny env guard. True (→ Block) if the command assigns any env var -/// whose KEY is not in [`SAFE_ENV_KEYS`], or passes an option to `env` (which can -/// run a string, clear, or unset the environment). Reads the PARSED tree so -/// quoting (`env "LD_PRELOAD=..."`) can't hide a key from the check. -pub(crate) fn script_sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> bool { +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum EnvRisk { + Safe, + Unvetted, + Injection, +} + +const INJECTION_ENV_KEYS: &[&str] = &[ + "LD_PRELOAD", + "LD_AUDIT", + "BASH_ENV", + "ENV", + "IFS", + "PATH", + "GIT_EXTERNAL_DIFF", + "GIT_PROXY_COMMAND", + "PROMPT_COMMAND", +]; + +const INJECTION_ENV_KEY_PREFIXES: &[&str] = &["DYLD_", "GIT_CONFIG"]; + +fn env_key_risk(key: &str) -> EnvRisk { + if is_safe_env_key(key) { + EnvRisk::Safe + } else if INJECTION_ENV_KEYS.contains(&key) + || INJECTION_ENV_KEY_PREFIXES + .iter() + .any(|p| key.starts_with(p)) + { + EnvRisk::Injection + } else { + EnvRisk::Unvetted + } +} + +/// Highest [`EnvRisk`] across the script's env assignments (inline `KEY=val` +/// and `env`-form). Reads the PARSED tree so quoting (`env "LD_PRELOAD=..."`) +/// can't hide a key. +pub(crate) fn script_env_risk(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> EnvRisk { + let mut risk = EnvRisk::Safe; // (a) Inline `KEY=val cmd` assignments are `variable_assignment` nodes // (stripped from PlainCommand words), so walk the tree for them. let mut stack = vec![root]; while let Some(node) = stack.pop() { - if node.kind() == "variable_assignment" && !is_safe_env_key(assignment_key(node, src)) { - return true; + if node.kind() == "variable_assignment" { + risk = risk.max(env_key_risk(assignment_key(node, src))); } let mut cursor = node.walk(); for child in node.children(&mut cursor) { @@ -761,29 +806,29 @@ pub(crate) fn script_sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCom } // (b) `env`-form assignments/options, even behind other wrappers // (e.g. `timeout 5 env LD_PRELOAD=...`). - cmds.iter().any(|c| command_env_is_unsafe(c.words())) + cmds.iter() + .fold(risk, |risk, c| risk.max(command_env_risk(c.words()))) } /// Walk a command's wrapper chain; for each `env` invocation treat any option /// flag (`-S`/`-i`/`-u`/`-C`/...) or an assignment KEY outside [`SAFE_ENV_KEYS`] /// as exec-affecting → unsafe. Covers nested wrappers like `timeout 5 env ...`. -fn command_env_is_unsafe(words: &[String]) -> bool { +fn command_env_risk(words: &[String]) -> EnvRisk { + let mut risk = EnvRisk::Safe; let mut current = words; for _ in 0..8 { if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env") { + let mut options_done = false; for arg in ¤t[1..] { if arg == "--" { - break; // end of env options; the rest is the command + options_done = true; + continue; } - if arg.starts_with('-') { - return true; // env option alters/clears the exec environment + if !options_done && arg.starts_with('-') { + return EnvRisk::Injection; } match arg.split_once('=') { - Some((key, _)) => { - if !is_safe_env_key(key) { - return true; - } - } + Some((key, _)) => risk = risk.max(env_key_risk(key)), None => break, // first plain word is the inner command } } @@ -793,7 +838,7 @@ fn command_env_is_unsafe(words: &[String]) -> bool { None => break, } } - false + risk } /// The variable name assigned by a `variable_assignment` node — its @@ -841,9 +886,9 @@ impl PermissionClassifier for HeuristicPermissionClassifier { access: &'a AccessKind, access_detail: Option<&'a str>, context: ClassifierContext, - ) -> Pin + Send + 'a>> { + ) -> Pin + Send + 'a>> { let v = Self::classify_sync(tool_name, access, access_detail, &context); - Box::pin(async move { v }) + Box::pin(async move { v.into() }) } } @@ -1115,26 +1160,39 @@ pub fn build_classifier_messages( /// Parse model JSON / text into a verdict (`shouldBlock` mapping). pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict { + parse_classifier_model_output(text).verdict +} + +pub const CLASSIFIER_REASON_MAX_LEN: usize = 400; + +fn classifier_reason(v: &serde_json::Value) -> Option { + v.get("reason") + .and_then(|r| r.as_str()) + .map(|r| r.split_whitespace().collect::>().join(" ")) + .filter(|r| !r.is_empty()) + .map(|r| xai_grok_tools::util::truncate_line(&r, CLASSIFIER_REASON_MAX_LEN).into_owned()) +} + +pub fn parse_classifier_model_output(text: &str) -> ClassifierOutcome { let trimmed = text.trim(); if trimmed.is_empty() { - return ClassifierVerdict::Unavailable; + return ClassifierVerdict::Unavailable.into(); } // Prefer JSON object with shouldBlock - if let Ok(v) = serde_json::from_str::(trimmed) { - if let Some(b) = v.get("shouldBlock").and_then(|x| x.as_bool()) { - return if b { + if let Ok(v) = serde_json::from_str::(trimmed) + && let Some(b) = v + .get("shouldBlock") + .or_else(|| v.get("should_block")) + .and_then(|x| x.as_bool()) + { + return ClassifierOutcome { + verdict: if b { ClassifierVerdict::Block } else { ClassifierVerdict::Allow - }; - } - if let Some(b) = v.get("should_block").and_then(|x| x.as_bool()) { - return if b { - ClassifierVerdict::Block - } else { - ClassifierVerdict::Allow - }; - } + }, + reason: classifier_reason(&v), + }; } // Fenced or embedded JSON if let Some(start) = trimmed.find('{') @@ -1146,15 +1204,18 @@ pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict { .or_else(|| v.get("should_block")) .and_then(|x| x.as_bool()) { - return if b { - ClassifierVerdict::Block - } else { - ClassifierVerdict::Allow + return ClassifierOutcome { + verdict: if b { + ClassifierVerdict::Block + } else { + ClassifierVerdict::Allow + }, + reason: classifier_reason(&v), }; } let lower = trimmed.to_ascii_lowercase(); if lower.contains("\"shouldblock\": true") || lower.contains("shouldblock\":true") { - return ClassifierVerdict::Block; + return ClassifierVerdict::Block.into(); } // Deliberately do NOT infer Allow from a loose `"shouldBlock": false` substring: // narrative prose or multiple JSON fragments (from `rfind('}')`) can contain it @@ -1165,9 +1226,9 @@ pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict { // and flips the verdict, so only honor an unambiguous one-word reply; // anything else is Unavailable → conservative heuristic fallback. match lower.trim() { - "block" | "blocked" | "deny" | "denied" => ClassifierVerdict::Block, - "allow" | "allowed" | "approve" | "approved" => ClassifierVerdict::Allow, - _ => ClassifierVerdict::Unavailable, + "block" | "blocked" | "deny" | "denied" => ClassifierVerdict::Block.into(), + "allow" | "allowed" | "approve" | "approved" => ClassifierVerdict::Allow.into(), + _ => ClassifierVerdict::Unavailable.into(), } } @@ -1270,7 +1331,7 @@ impl PermissionClassifier for LlmPermissionClassifier { access: &'a AccessKind, access_detail: Option<&'a str>, context: ClassifierContext, - ) -> Pin + Send + 'a>> { + ) -> Pin + Send + 'a>> { Box::pin(async move { // Deterministic pre-pass: a provable heuristic Allow skips the model // (no side-query latency, no false block); anything unprovable still @@ -1282,7 +1343,7 @@ impl PermissionClassifier for LlmPermissionClassifier { &context, ); if heuristic == ClassifierVerdict::Allow { - return ClassifierVerdict::Allow; + return ClassifierVerdict::Allow.into(); } let messages = build_classifier_messages( tool_name, @@ -1308,14 +1369,14 @@ impl PermissionClassifier for LlmPermissionClassifier { None }; if let Some(text) = model_text { - let v = parse_classifier_model_text(&text); - if v != ClassifierVerdict::Unavailable { - return v; + let outcome = parse_classifier_model_output(&text); + if outcome.verdict != ClassifierVerdict::Unavailable { + return outcome; } } // Model unavailable / unparseable: fall back to the heuristic verdict // computed above (non-Allow here — Allow already short-circuited). - heuristic + heuristic.into() }) } } @@ -1415,7 +1476,8 @@ mod tests { Some("ls"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Allow ); let block = FixedClassifier(ClassifierVerdict::Block); @@ -1427,7 +1489,8 @@ mod tests { Some("rm -rf /"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Block ); } @@ -1655,6 +1718,60 @@ mod tests { assert_eq!(v("cargo test"), ClassifierVerdict::Allow); } + #[test] + fn env_risk_tiers() { + let risk = |cmd: &str| { + let tree = try_parse_shell(cmd).expect(cmd); + let cmds = try_parse_word_only_commands_sequence(&tree, cmd).unwrap_or_default(); + script_env_risk(tree.root_node(), cmd, &cmds) + }; + assert_eq!(risk("RUST_LOG=debug cargo test"), EnvRisk::Safe); + assert_eq!(risk("cargo test"), EnvRisk::Safe); + + for cmd in [ + "GH_HOST=github.example.com gh pr view 3135", + "FOO=bar make test", + "out=$(gh pr view 3135); echo \"$out\"", + "env FOO=1 cargo test", + "GIT_SSH_COMMAND=/x git fetch", + "SSH_ASKPASS=/x ssh host", + "PYTHONPATH=/x python s.py", + "NODE_OPTIONS=--require=/x npm test", + "KUBECONFIG=/x kubectl get pods", + "XDG_CONFIG_HOME=/x git status", + "LD_LIBRARY_PATH=/x ./app", + ] { + assert_eq!(risk(cmd), EnvRisk::Unvetted, "{cmd}"); + } + + assert_eq!( + risk("bash -c 'GIT_CONFIG_COUNT=1 git status'"), + EnvRisk::Safe + ); + assert_eq!(risk("sh -c 'echo hi'"), EnvRisk::Safe); + + assert_eq!( + risk("GH_HOST=x LD_PRELOAD=/x gh pr view 1"), + EnvRisk::Injection + ); + for cmd in [ + "LD_PRELOAD=/x cargo test", + "env \"DYLD_INSERT_LIBRARIES=/x\" cargo test", + "GIT_CONFIG_COUNT=1 git status", + "PATH=/tmp cargo test", + "BASH_ENV=/x bash -c true", + "IFS=x sh -c cmd", + "env -i cargo test", + "env -S 'rm -rf ~' ls", + "env -- LD_PRELOAD=/x cargo test", + "GIT_EXTERNAL_DIFF=/x git diff", + "GIT_PROXY_COMMAND=/x git fetch", + "PROMPT_COMMAND=/x bash", + ] { + assert_eq!(risk(cmd), EnvRisk::Injection, "{cmd}"); + } + } + /// `cp`/`mv` write/replace arbitrary destinations the redirect guard can't /// see, so they must NOT be auto-allowed (`cp evil ~/.bashrc`). #[test] @@ -2187,7 +2304,8 @@ mod tests { Some("cargo test"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Allow ); // dangerous stays blocked via heuristic @@ -2199,7 +2317,8 @@ mod tests { Some("rm -rf /"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Block ); @@ -2212,7 +2331,8 @@ mod tests { Some("cargo test"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Allow, "unparseable model text → heuristic allow for cargo" ); @@ -2236,7 +2356,8 @@ mod tests { Some("cargo test"), ClassifierContext::default(), ) - .await, + .await + .verdict, ClassifierVerdict::Allow ); } @@ -2259,6 +2380,7 @@ mod tests { ClassifierContext::default(), ) .await + .verdict }; // Provably routine chains (incl. the reported `find; grep` repro) must // allow despite the model saying block. @@ -2312,12 +2434,43 @@ mod tests { Some("cargo test"), ctx, ) - .await, + .await + .verdict, ClassifierVerdict::Block, "hostile transcript must reach the model, whose block stands" ); } + #[tokio::test] + async fn classifier_outcome_threads_model_reason() { + let block = LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"t","shouldBlock":true,"reason":"pushes to a remote"}"#, + ); + let outcome = block + .classify( + "run_terminal_command", + &AccessKind::Bash("git push origin main".into()), + Some("git push origin main"), + ClassifierContext::default(), + ) + .await; + assert_eq!(outcome.verdict, ClassifierVerdict::Block); + assert_eq!(outcome.reason.as_deref(), Some("pushes to a remote")); + + let blank = + parse_classifier_model_output(r#"{"thinking":"t","shouldBlock":true,"reason":" "}"#); + assert_eq!(blank.verdict, ClassifierVerdict::Block); + assert_eq!(blank.reason, None); + let terse = parse_classifier_model_output("block"); + assert_eq!(terse.verdict, ClassifierVerdict::Block); + assert_eq!(terse.reason, None); + let fenced = parse_classifier_model_output( + "```json\n{\"thinking\":\"t\",\"shouldBlock\":true,\"reason\":\"exfil\"}\n```", + ); + assert_eq!(fenced.verdict, ClassifierVerdict::Block); + assert_eq!(fenced.reason.as_deref(), Some("exfil")); + } + /// The routine-prefix additions cover everyday read-only / navigation /// commands; their mutating siblings stay blocked (word-boundary scoping). #[test] diff --git a/crates/codegen/xai-grok-workspace/src/permission/manager.rs b/crates/codegen/xai-grok-workspace/src/permission/manager.rs index 9f13f13..8c514cd 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/manager.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/manager.rs @@ -7,11 +7,11 @@ use chrono::Utc; use tokio::sync::{mpsc, oneshot}; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; -use crate::permission::auto_mode::script_sets_unsafe_env; +use crate::permission::auto_mode::{EnvRisk, script_env_risk}; use crate::permission::bash_command_splitting::{ is_setup_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers, }; -use crate::permission::policy::CompiledPolicy; +use crate::permission::policy::{CompiledPolicy, shell_dash_c_script}; use crate::permission::prompter::{AcpPrompter, PromptOutcome}; use crate::permission::shell_access::{ combine_decisions, command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink, @@ -38,6 +38,9 @@ mod reasons { pub const AUTO_FAST_PATH: &str = "auto_fast_path"; pub const AUTO_CLASSIFIER_ALLOW: &str = "auto_classifier_allow"; pub const AUTO_CLASSIFIER_BLOCK: &str = "auto_classifier_block"; + pub const AUTO_CLASSIFIER_DENY: &str = "auto_classifier_deny"; + pub const AUTO_CLASSIFIER_UNAVAILABLE: &str = "auto_classifier_unavailable"; + pub const AUTO_DENIAL_LIMIT: &str = "auto_denial_limit"; pub const SANDBOX_AUTO: &str = "sandbox_auto"; pub const PERSISTED_GRANT: &str = "persisted_grant"; pub const SESSION_GRANT: &str = "session_grant"; @@ -46,9 +49,18 @@ mod reasons { pub const SESSION_DENY: &str = "session_deny"; pub const PROMPT_DENY: &str = "prompt_deny"; pub const NEEDS_USER: &str = "needs_user"; + pub const BASH_REQUEST_FLOOR: &str = "bash_request_floor"; + pub const OPAQUE_SHELL: &str = "opaque_shell"; pub const REQUESTER_GONE: &str = "requester_gone"; } +pub const AUTO_DENY_CONSECUTIVE_LIMIT: u32 = 3; +pub const AUTO_DENY_TOTAL_LIMIT: u32 = 20; + +const AUTO_DENY_GUIDANCE: &str = "Take a safer approach that stays within what the user asked \ + for; do not retry this exact action or attempt to work around the denial. If no safer \ + alternative exists, ask the user how to proceed."; + /// Canonical permission-mode string for the uploaded artifact. Matches /// `config.ui.permission_mode` (hyphenated) for trace-internal consistency, /// deliberately diverging from the telemetry enum's underscore Mixpanel serde. @@ -364,9 +376,10 @@ pub(crate) enum SegmentEvaluation { struct BashEvaluation { segments: SegmentEvaluation, writes_real_file: bool, - sets_unsafe_env: bool, + env_risk: EnvRisk, exact_grant: bool, all_segments_granted: bool, + has_opaque_shell: bool, } /// Parse and classify one Bash request once, keeping ordinary segment outcome @@ -377,16 +390,17 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> return BashEvaluation { segments: SegmentEvaluation::Unparseable, writes_real_file: false, - sets_unsafe_env: false, + env_risk: EnvRisk::Safe, exact_grant, all_segments_granted: false, + has_opaque_shell: false, }; }; let writes_real_file = command_write_paths_in_tree(tree.root_node(), cmd) .into_iter() .any(|path| !is_safe_write_sink(&path)); let segments = try_parse_word_only_commands_sequence(&tree, cmd); - let sets_unsafe_env = script_sets_unsafe_env( + let env_risk = script_env_risk( tree.root_node(), cmd, segments.as_deref().unwrap_or_default(), @@ -395,15 +409,17 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> return BashEvaluation { segments: SegmentEvaluation::Unparseable, writes_real_file, - sets_unsafe_env, + env_risk, exact_grant, all_segments_granted: false, + has_opaque_shell: false, }; }; let mut needs_prompt: Vec = Vec::new(); let mut any_dangerous = false; let mut via_session_grant = false; let mut all_segments_granted = true; + let mut has_opaque_shell = false; for parsed in segments { let raw_words = parsed.words(); // Peel wrapper commands like `timeout 30 …`, `env FOO=1 …`, `nice -n 5 …` @@ -411,6 +427,11 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> // such as `timeout 30 rm -rf /tmp/foo` would be treated as a benign // `timeout` invocation and silently auto-allowed. let words = unwrap_wrappers(raw_words); + if shell_dash_c_script(words).is_some() + || words.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("eval") + { + has_opaque_shell = true; + } if is_setup_command(words) { continue; } @@ -427,9 +448,10 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> "User previously rejected `{d}` for this session" )), writes_real_file, - sets_unsafe_env, + env_risk, exact_grant, all_segments_granted, + has_opaque_shell, }; } @@ -473,9 +495,10 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> BashEvaluation { segments, writes_real_file, - sets_unsafe_env, + env_risk, exact_grant, all_segments_granted, + has_opaque_shell, } } @@ -764,12 +787,26 @@ fn bash_write_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool } fn bash_unsafe_env_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { - evaluation.is_some_and(|evaluation| evaluation.sets_unsafe_env && !evaluation.exact_grant) + evaluation + .is_some_and(|evaluation| evaluation.env_risk != EnvRisk::Safe && !evaluation.exact_grant) +} + +fn bash_opaque_shell_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { + evaluation.is_some_and(|evaluation| evaluation.has_opaque_shell && !evaluation.exact_grant) } fn bash_request_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { bash_write_floor_requires_prompt(evaluation) || bash_unsafe_env_floor_requires_prompt(evaluation) + || bash_opaque_shell_floor_requires_prompt(evaluation) +} + +fn bash_request_floor_defers_to_classifier(evaluation: Option<&BashEvaluation>) -> bool { + evaluation.is_some_and(|evaluation| { + !evaluation.writes_real_file + && !evaluation.has_opaque_shell + && evaluation.env_risk == EnvRisk::Unvetted + }) } fn sandbox_may_auto_allow_bash(evaluation: Option<&BashEvaluation>, sandbox_active: bool) -> bool { @@ -1062,6 +1099,8 @@ fn spawn_permission_manager_with_pin( // fallback always uses the actor's transcript turns). let mut auto_classifier: Option = Some(crate::permission::auto_mode::default_auto_mode_classifier()); + let mut auto_consecutive_denials: u32 = 0; + let mut auto_total_denials: u32 = 0; // Recent turns + project AGENTS.md for classifier context (set by session). let mut classifier_turns: Vec = Vec::new(); let mut recorded_permission_decisions: Vec = @@ -1332,6 +1371,24 @@ fn spawn_permission_manager_with_pin( continue; } + if auto_mode + && !policy_forced_prompt + && !shell_forced_prompt + && !protected_edit + && !bash_request_floor_requires_prompt(bash_evaluation.as_ref()) + && matches!(policy_decision, Some(Decision::Allow)) + { + tracing::info!( + tool = ?tool_name, + source = "policy", + "permission policy: allow rule matched (before auto classifier)" + ); + let decision = Decision::Allow; + emit_event(&decision, true, false, None, Some(reasons::POLICY_ALLOW)); + let _ = respond_to.send(decision); + continue; + } + // Auto mode: classifier + fast-paths (not silent always-approve). // Policy deny already handled; forced Ask falls through unless // fast-path/classifier allows. Policy Ask still prompts below @@ -1340,7 +1397,8 @@ fn spawn_permission_manager_with_pin( if auto_mode && !policy_forced_prompt && !shell_forced_prompt - && !bash_request_floor_requires_prompt(bash_evaluation.as_ref()) + && (!bash_request_floor_requires_prompt(bash_evaluation.as_ref()) + || bash_request_floor_defers_to_classifier(bash_evaluation.as_ref())) { use crate::permission::auto_mode::{ AutoFastPath, ClassifierVerdict, access_requires_user_interaction, @@ -1372,7 +1430,7 @@ fn spawn_permission_manager_with_pin( auto_prompt_reason = Some(reasons::NEEDS_USER); } AutoFastPath::Classify => { - let verdict = if let Some(ref clf) = auto_classifier { + let outcome = if let Some(ref clf) = auto_classifier { use crate::permission::auto_mode::ClassifierContext; let mut turns = classifier_turns.clone(); turns.extend(recorded_permission_decisions.iter().cloned()); @@ -1392,9 +1450,9 @@ fn spawn_permission_manager_with_pin( } else { // No classifier wired: treat as unavailable, which // prompts the user (never a silent allow). - Some(ClassifierVerdict::Unavailable) + Some(ClassifierVerdict::Unavailable.into()) }; - let Some(verdict) = verdict else { + let Some(outcome) = outcome else { tracing::info!(tool = %tool_name, "permission requester gone; classify abandoned"); emit_event( &Decision::Cancelled, @@ -1405,15 +1463,13 @@ fn spawn_permission_manager_with_pin( ); continue; }; - // Allow runs without a prompt; Block/Unavailable - // both surface the interactive picker (never a - // silent reject). - match verdict { + match outcome.verdict { ClassifierVerdict::Allow => { tracing::debug!( tool = %tool_name, "auto mode: classifier allow" ); + auto_consecutive_denials = 0; let decision = Decision::Allow; emit_event( &decision, @@ -1425,15 +1481,72 @@ fn spawn_permission_manager_with_pin( let _ = respond_to.send(decision); continue; } - ClassifierVerdict::Block | ClassifierVerdict::Unavailable => { + ClassifierVerdict::Block + if bash_request_floor_requires_prompt( + bash_evaluation.as_ref(), + ) => + { tracing::info!( tool = %tool_name, - "auto mode: classifier blocked — prompting user" + "auto mode: classifier declined floor-deferred command — prompting user" ); - // Fall through to interactive prompter. auto_forced_prompt = true; auto_prompt_reason = Some(reasons::AUTO_CLASSIFIER_BLOCK); } + ClassifierVerdict::Block + if auto_consecutive_denials + < AUTO_DENY_CONSECUTIVE_LIMIT + && auto_total_denials < AUTO_DENY_TOTAL_LIMIT => + { + auto_consecutive_denials += 1; + auto_total_denials += 1; + tracing::info!( + tool = %tool_name, + consecutive = auto_consecutive_denials, + total = auto_total_denials, + "auto mode: classifier blocked — denying and continuing" + ); + let reason = match &outcome.reason { + Some(r) => format!( + "Auto mode blocked this action ({}). \ + {AUTO_DENY_GUIDANCE}", + r.trim_end_matches('.') + ), + None => format!( + "Auto mode blocked this action. \ + {AUTO_DENY_GUIDANCE}" + ), + }; + let decision = Decision::PolicyDeny(reason); + emit_event( + &decision, + false, + false, + None, + Some(reasons::AUTO_CLASSIFIER_DENY), + ); + let _ = respond_to.send(decision); + continue; + } + ClassifierVerdict::Block => { + tracing::info!( + tool = %tool_name, + consecutive = auto_consecutive_denials, + total = auto_total_denials, + "auto mode: denial limit reached — prompting user" + ); + auto_forced_prompt = true; + auto_prompt_reason = Some(reasons::AUTO_DENIAL_LIMIT); + } + ClassifierVerdict::Unavailable => { + tracing::info!( + tool = %tool_name, + "auto mode: classifier unavailable — prompting user" + ); + auto_forced_prompt = true; + auto_prompt_reason = + Some(reasons::AUTO_CLASSIFIER_UNAVAILABLE); + } } } } @@ -1658,8 +1771,14 @@ fn spawn_permission_manager_with_pin( // A policy/shell `ask` wins; else the auto-mode reason; else unapproved. let prompt_trigger = if policy_forced_prompt || shell_forced_prompt { reasons::POLICY_ASK + } else if let Some(reason) = auto_prompt_reason { + reason + } else if bash_opaque_shell_floor_requires_prompt(bash_evaluation.as_ref()) { + reasons::OPAQUE_SHELL + } else if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) { + reasons::BASH_REQUEST_FLOOR } else { - auto_prompt_reason.unwrap_or(reasons::NEEDS_USER) + reasons::NEEDS_USER }; if respond_to.is_closed() { tracing::info!(tool = %tool_name, "permission requester gone; prompt suppressed"); @@ -1882,6 +2001,9 @@ fn spawn_permission_manager_with_pin( .drain(..len - MAX_RECORDED_PERMISSION_DECISIONS); } } + if user_prompted && outcome_str != "error" { + auto_consecutive_denials = 0; + } let trigger = if matches!(decision, Decision::Cancelled) && respond_to.is_closed() { @@ -2980,14 +3102,14 @@ mod tests { context: crate::permission::auto_mode::ClassifierContext, ) -> std::pin::Pin< Box< - dyn std::future::Future + dyn std::future::Future + Send + 'a, >, > { self.seen.lock().unwrap().push(context); let v = self.verdict; - Box::pin(async move { v }) + Box::pin(async move { v.into() }) } } @@ -3643,6 +3765,237 @@ mod tests { .await; } + #[tokio::test] + async fn floor_prompt_records_bash_request_floor_reason() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + let d = mgr + .request( + AccessKind::Bash("cat payload > out".into()), + tool_call(), + None, + None, + None, + ) + .await; + assert!(matches!(d, Decision::Reject(_))); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!(ev.decision_reason.as_deref(), Some("bash_request_floor")); + assert!(ev.user_prompted); + }) + .await; + } + + #[tokio::test] + async fn auto_mode_unvetted_env_defers_to_classifier_allow() { + use crate::permission::auto_mode::LlmPermissionClassifier; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"read-only","shouldBlock":false,"reason":"pr read"}"#, + ))); + for cmd in [ + "GH_HOST=github.example.com gh pr view 3135 --json title", + "PYTHONPATH=/x python s.py", + "out=$(gh pr view 3135); echo \"$out\"", + ] { + let d = mgr + .request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None) + .await; + assert!(matches!(d, Decision::Allow), "{cmd}: {d:?}"); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!( + ev.decision_reason.as_deref(), + Some("auto_classifier_allow"), + "{cmd}" + ); + } + assert_eq!(prompts.borrow().len(), 0); + }) + .await; + } + + #[tokio::test] + async fn auto_mode_injection_env_prompts_despite_classifier_allow() { + use crate::permission::auto_mode::LlmPermissionClassifier; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"looks fine","shouldBlock":false,"reason":"ok"}"#, + ))); + for cmd in [ + UNSAFE_GIT_STATUS, + "LD_PRELOAD=/tmp/e.so ls", + "env -i git status", + ] { + let d = mgr + .request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None) + .await; + assert!(matches!(d, Decision::Reject(_)), "{cmd}: {d:?}"); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!( + ev.decision_reason.as_deref(), + Some("bash_request_floor"), + "{cmd}" + ); + } + assert_eq!(prompts.borrow().len(), 3); + }) + .await; + } + + #[tokio::test] + async fn auto_mode_opaque_shell_prompts_despite_classifier_allow() { + use crate::permission::auto_mode::LlmPermissionClassifier; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"looks fine","shouldBlock":false,"reason":"ok"}"#, + ))); + for cmd in [ + "bash -c 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=cat git status'", + "sh -c 'LD_PRELOAD=/x ls'", + "bash -c 'echo hi'", + "eval 'echo hi'", + "env bash -c 'echo hi'", + ] { + let d = mgr + .request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None) + .await; + assert!(matches!(d, Decision::Reject(_)), "{cmd}: {d:?}"); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!(ev.decision_reason.as_deref(), Some("opaque_shell"), "{cmd}"); + } + assert_eq!(prompts.borrow().len(), 5); + }) + .await; + } + + #[tokio::test] + async fn injection_env_runs_under_yolo() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_yolo_mode(true); + let d = mgr + .request( + AccessKind::Bash(UNSAFE_GIT_STATUS.into()), + tool_call(), + None, + None, + None, + ) + .await; + assert!(matches!(d, Decision::Allow), "{d:?}"); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!(ev.decision_reason.as_deref(), Some("yolo")); + assert_eq!(prompts.borrow().len(), 0); + }) + .await; + } + + #[tokio::test] + async fn auto_mode_write_floor_prompts_despite_classifier_allow() { + use crate::permission::auto_mode::LlmPermissionClassifier; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"looks fine","shouldBlock":false,"reason":"ok"}"#, + ))); + let d = mgr + .request( + AccessKind::Bash("V=1 cat payload > out".into()), + tool_call(), + None, + None, + None, + ) + .await; + assert!(matches!(d, Decision::Reject(_)), "{d:?}"); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!(ev.decision_reason.as_deref(), Some("bash_request_floor")); + assert_eq!(prompts.borrow().len(), 1); + }) + .await; + } + + #[tokio::test] + async fn auto_mode_unvetted_env_classifier_block_prompts() { + use crate::permission::auto_mode::LlmPermissionClassifier; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, mut events) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( + r#"{"thinking":"suspicious","shouldBlock":true,"reason":"no"}"#, + ))); + let d = mgr + .request( + AccessKind::Bash("CUSTOM_TOKEN=x curl-ish --post".into()), + tool_call(), + None, + None, + None, + ) + .await; + assert!(matches!(d, Decision::Reject(_))); + assert_eq!(prompts.borrow().len(), 1); + let ev = events.try_recv().expect("event must be emitted"); + assert_eq!(ev.decision_reason.as_deref(), Some("auto_classifier_block")); + }) + .await; + } + #[tokio::test] async fn protected_edit_floor_covers_auto_config_allow_and_dont_ask() { use crate::permission::types::{PermissionRule, RuleAction, ToolFilter}; @@ -4982,34 +5335,81 @@ mod tests { #[test] fn unsafe_environment_detection_covers_script_forms() { let state = PermissionState::default(); - for (cmd, unsafe_env) in [ - (UNSAFE_GIT_STATUS, true), + for (cmd, env_risk) in [ + (UNSAFE_GIT_STATUS, EnvRisk::Injection), ( concat!( "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor ", "GIT_CONFIG_VALUE_0=/tmp/pwn git status" ), - true, + EnvRisk::Injection, ), ( concat!( "set -a; GIT_CONFIG_COUNT=1; GIT_CONFIG_KEY_0=core.fsmonitor; ", "GIT_CONFIG_VALUE_0=/tmp/pwn; git status" ), - true, + EnvRisk::Injection, ), - ("RUST_LOG=debug git status", false), + ("LD_PRELOAD=/tmp/e.so ls", EnvRisk::Injection), + ("env -i git status", EnvRisk::Injection), + ( + "GH_HOST=github.example.com gh pr view 3135", + EnvRisk::Unvetted, + ), + ("KUBECONFIG=/x kubectl get pods", EnvRisk::Unvetted), + ("out=$(gh pr view 3135); echo \"$out\"", EnvRisk::Unvetted), + ("RUST_LOG=debug git status", EnvRisk::Safe), ] { let evaluation = evaluate_bash(cmd, &state, true); - assert_eq!(evaluation.sets_unsafe_env, unsafe_env, "{cmd}"); + assert_eq!(evaluation.env_risk, env_risk, "{cmd}"); assert_eq!( bash_unsafe_env_floor_requires_prompt(Some(&evaluation)), - unsafe_env, + env_risk != EnvRisk::Safe, + "{cmd}" + ); + assert_eq!( + bash_request_floor_defers_to_classifier(Some(&evaluation)), + env_risk == EnvRisk::Unvetted, "{cmd}" ); } } + #[test] + fn injection_env_floor_respects_exact_grant() { + let cmd = UNSAFE_GIT_STATUS; + let ungranted = evaluate_bash(cmd, &PermissionState::default(), true); + assert_eq!(ungranted.env_risk, EnvRisk::Injection); + assert!(bash_unsafe_env_floor_requires_prompt(Some(&ungranted))); + assert!(!bash_request_floor_defers_to_classifier(Some(&ungranted))); + + let granted_state = PermissionState { + allowed_bash_commands: HashSet::from([cmd.to_owned()]), + ..Default::default() + }; + let granted = evaluate_bash(cmd, &granted_state, true); + assert!(!bash_unsafe_env_floor_requires_prompt(Some(&granted))); + } + + #[test] + fn opaque_shell_floor_and_exact_grant() { + let cmd = "bash -c 'GIT_CONFIG_COUNT=1 git status'"; + let ungranted = evaluate_bash(cmd, &PermissionState::default(), true); + assert!(ungranted.has_opaque_shell); + assert_eq!(ungranted.env_risk, EnvRisk::Safe); + assert!(bash_opaque_shell_floor_requires_prompt(Some(&ungranted))); + assert!(bash_request_floor_requires_prompt(Some(&ungranted))); + assert!(!bash_request_floor_defers_to_classifier(Some(&ungranted))); + + let granted_state = PermissionState { + allowed_bash_commands: HashSet::from([cmd.to_owned()]), + ..Default::default() + }; + let granted = evaluate_bash(cmd, &granted_state, true); + assert!(!bash_opaque_shell_floor_requires_prompt(Some(&granted))); + } + #[test] fn unsafe_env_floor_blocks_broad_grants_but_preserves_exact_decisions() { let cmd = UNSAFE_GIT_STATUS; @@ -5024,7 +5424,7 @@ mod tests { ..Default::default() }; let evaluation = evaluate_bash(cmd, &state, true); - assert!(evaluation.sets_unsafe_env); + assert_ne!(evaluation.env_risk, EnvRisk::Safe); assert_eq!( bash_grant_pre_decision( cmd, @@ -5553,11 +5953,10 @@ mod tests { "classifier allow must allow without user click, got {d:?}" ); - // Classifier block denies. mgr.set_classifier(Some(Arc::new(FixedClassifier(ClassifierVerdict::Block)))); let d = mgr .request( - AccessKind::Bash("rm -rf /".into()), + AccessKind::Bash("git push origin main".into()), dummy_update.clone(), None, None, @@ -5565,8 +5964,8 @@ mod tests { ) .await; assert!( - matches!(d, Decision::Reject(_)), - "classifier block must deny, got {d:?}" + matches!(d, Decision::PolicyDeny(_)), + "classifier block must deny-and-continue, got {d:?}" ); // Always-approve (yolo) skips classifier entirely. @@ -5794,22 +6193,16 @@ mod tests { ) .await; assert!( - matches!(d, Decision::Reject(_)), - "LLM block on real gate must deny, got {d:?}" + matches!(&d, Decision::PolicyDeny(r) if r.contains("exfil")), + "LLM block on real gate must deny-and-continue with the \ + classifier reason threaded through, got {d:?}" ); }) .await; } - /// Guardrail: in interactive AUTO mode a classifier BLOCK must surface the - /// standard permission picker on the FIRST block (no silent denial), and - /// that picker is the SAME one the normal interactive path builds — it must - /// still offer "enable always-approve" at position 0. Drives the real prompt - /// through `manager_with_recording_client` so a refactor that swapped in an - /// auto-specific picker on the block path would fail here (a pure - /// `build_options` unit test cannot catch that). #[tokio::test] - async fn auto_classifier_block_prompt_still_offers_always_approve() { + async fn auto_classifier_block_denies_then_escalates_to_prompt() { use crate::permission::auto_mode::LlmPermissionClassifier; use crate::permission::prompter::ENABLE_ALWAYS_APPROVE_OPTION_ID; let local = tokio::task::LocalSet::new(); @@ -5824,48 +6217,184 @@ mod tests { let (mgr, _e) = manager_with_recording_client(&cwd, None, client, ClientType::GrokPager); mgr.set_auto_mode(true); - // Force classify to Block. Interactive auto mode must now prompt - // on the FIRST block instead of denying. mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text( - r#"{"thinking":"t","shouldBlock":true,"reason":"x"}"#, + r#"{"thinking":"t","shouldBlock":true,"reason":"reaches beyond the machine"}"#, ))); - // A non-allowlisted MCP tool (name has no "ask_user"/"confirm"+ - // "human") routes to `AutoFastPath::Classify`, not the needs-user - // fast path, so the classifier — and thus the prompt-on-block — runs. - let d = tokio::time::timeout( - std::time::Duration::from_secs(5), + let request = || async { + tokio::time::timeout( + std::time::Duration::from_secs(5), + mgr.request( + AccessKind::MCPTool { + name: "test_server__do_thing".into(), + input: serde_json::Value::Null, + }, + tool_call(), + None, + None, + None, + ), + ) + .await + .expect("classifier-block request must resolve, not hang") + }; + + for i in 0..AUTO_DENY_CONSECUTIVE_LIMIT { + let d = request().await; + assert!( + matches!(&d, Decision::PolicyDeny(r) if r.contains("reaches beyond the machine")), + "block #{} within budget must PolicyDeny with the classifier reason, got {d:?}", + i + 1 + ); + assert_eq!( + prompts.borrow().len(), + 0, + "deny-and-continue must not prompt within the budget" + ); + } + + let d = request().await; + assert!( + matches!(d, Decision::Reject(_)), + "escalated prompt is answered reject-once by the recording client, got {d:?}" + ); + { + let recorded = prompts.borrow(); + assert_eq!( + recorded.len(), + 1, + "the block past the consecutive limit must prompt exactly once" + ); + assert_eq!( + recorded[0].options.first().map(|o| o.option_id.0.as_ref()), + Some(ENABLE_ALWAYS_APPROVE_OPTION_ID), + "escalation picker must still offer enable-always-approve at position 0" + ); + } + + let d = request().await; + assert!( + matches!(d, Decision::PolicyDeny(_)), + "after a human decision the consecutive budget must reset, got {d:?}" + ); + assert_eq!(prompts.borrow().len(), 1, "no second prompt after reset"); + }) + .await; + } + + #[tokio::test] + async fn auto_classifier_total_denial_limit_escalates() { + use crate::permission::auto_mode::{ + ClassifierContext, ClassifierOutcome, ClassifierVerdict, PermissionClassifier, + }; + use std::sync::atomic::{AtomicU32, Ordering}; + + struct CyclingClassifier(AtomicU32); + impl PermissionClassifier for CyclingClassifier { + fn classify<'a>( + &'a self, + _tool_name: &'a str, + _access: &'a AccessKind, + _access_detail: Option<&'a str>, + _context: ClassifierContext, + ) -> std::pin::Pin + Send + 'a>> + { + let i = self.0.fetch_add(1, Ordering::Relaxed); + let v = if i % 3 == 2 { + ClassifierVerdict::Allow + } else { + ClassifierVerdict::Block + }; + Box::pin(async move { v.into() }) + } + } + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let (mgr, _ev) = test_manager(&cwd, false, None); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(std::sync::Arc::new(CyclingClassifier( + AtomicU32::new(0), + )))); + let request = || async { mgr.request( - AccessKind::MCPTool { - name: "test_server__do_thing".into(), - input: serde_json::Value::Null, - }, + AccessKind::Bash("git push origin main".into()), tool_call(), None, None, None, - ), - ) - .await - .expect("classifier-block prompt must resolve, not hang"); + ) + .await + }; + + let cycles = AUTO_DENY_TOTAL_LIMIT / 2; + for cycle in 0..cycles { + for step in 0..3 { + let d = request().await; + if step == 2 { + assert!( + matches!(d, Decision::Allow), + "cycle {cycle} allow step must Allow, got {d:?}" + ); + } else { + assert!( + matches!(d, Decision::PolicyDeny(_)), + "cycle {cycle} block step must PolicyDeny under the cap, got {d:?}" + ); + } + } + } + + let d = request().await; assert!( matches!(d, Decision::Reject(_)), - "prompt is answered reject-once by the recording client, got {d:?}" + "block past the total cap must escalate to the prompt path, got {d:?}" ); + }) + .await; + } - let recorded = prompts.borrow(); - assert_eq!( - recorded.len(), - 1, - "the first classifier block must prompt exactly once (no silent denial)" - ); - // The prepended always-approve option must be position 0 of the - // picker (subsumes presence). - assert_eq!( - recorded[0].options.first().map(|o| o.option_id.0.as_ref()), - Some(ENABLE_ALWAYS_APPROVE_OPTION_ID), - "classifier-block picker must still offer enable-always-approve at position 0" - ); + #[tokio::test] + async fn auto_policy_allow_beats_classifier_deny() { + use crate::permission::auto_mode::{ClassifierVerdict, FixedClassifier}; + use crate::permission::types::{ + PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter, + }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let config = PermissionConfig::new(vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Bash, + pattern: Some("my-deploy-tool *".to_owned()), + pattern_mode: PatternMode::Glob, + }]); + let (mgr, _ev) = test_manager_with_config(&cwd, config, false); + mgr.set_auto_mode(true); + mgr.set_classifier(Some(std::sync::Arc::new(FixedClassifier( + ClassifierVerdict::Block, + )))); + for i in 0..(AUTO_DENY_CONSECUTIVE_LIMIT + 1) { + let d = mgr + .request( + AccessKind::Bash("my-deploy-tool --stage".into()), + tool_call(), + None, + None, + None, + ) + .await; + assert!( + matches!(d, Decision::Allow), + "policy allow must beat classifier deny (request #{}), got {d:?}", + i + 1 + ); + } }) .await; } diff --git a/crates/codegen/xai-grok-workspace/src/permission/mod.rs b/crates/codegen/xai-grok-workspace/src/permission/mod.rs index 8f7ae75..729a776 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/mod.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/mod.rs @@ -12,12 +12,13 @@ pub mod types; pub use auto_mode::{ AUTO_MODE_CLASSIFIER_SYSTEM_PROMPT, AutoFastPath, CLASSIFIER_TURN_MAX_LEN, ClassifierContext, - ClassifierMessage, ClassifierMessageRole, ClassifierPromptType, ClassifierTurn, - ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier, + ClassifierMessage, ClassifierMessageRole, ClassifierOutcome, ClassifierPromptType, + ClassifierTurn, ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier, HeuristicPermissionClassifier, LlmPermissionClassifier, PermissionClassifier, SharedClassifier, access_requires_user_interaction, auto_mode_fast_path, build_classifier_messages, classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access, - is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args, + is_auto_mode_allowlisted_tool_name, parse_classifier_model_output, parse_classifier_model_text, + permission_decision_args, }; pub use hub_permission::{ PermissionHookTransport, ToolServerPermissionTransport, access_kind_for_hub_tool, diff --git a/crates/codegen/xai-grok-workspace/src/permission/policy.rs b/crates/codegen/xai-grok-workspace/src/permission/policy.rs index 85ced39..767c887 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/policy.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/policy.rs @@ -168,7 +168,7 @@ impl From for CompiledPolicy { /// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation. /// Known residuals: option arguments (`-o pipefail`) and `+`-option words can /// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge. -fn shell_dash_c_script(words: &[String]) -> Option<&str> { +pub(crate) fn shell_dash_c_script(words: &[String]) -> Option<&str> { let program = words.first()?.rsplit(['/', '\\']).next()?; if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") { return None; diff --git a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs index 8f0a38d..23d969e 100644 --- a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs +++ b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs @@ -1755,7 +1755,7 @@ mod tests { let spec = xai_grok_hooks::config::HookSpec { name: "global/safety".to_string(), event: xai_grok_hooks::event::HookEventName::PreToolUse, - handler_type: "command".to_string(), + handler_type: xai_grok_hooks::config::HandlerType::Command, configured_matcher: Some("Bash".to_string()), matcher: None, enabled: true, @@ -1865,7 +1865,7 @@ mod tests { HookSpecWire { name, event, - handler_type, + handler_type: handler_type.as_str().to_string(), configured_matcher, enabled, command, @@ -1880,7 +1880,7 @@ mod tests { let spec = HookSpec { name: "global/safety".to_string(), event: xai_grok_hooks::event::HookEventName::PreToolUse, - handler_type: "command".to_string(), + handler_type: xai_grok_hooks::config::HandlerType::Command, configured_matcher: Some("Bash".to_string()), matcher: None, enabled: true,