Synced from monorepo

Changes:
- Stop hooks for session lifecycle
- Add x.ai/session/state and x.ai/session/import ACP methods
- Deny-and-continue for auto-mode classifier blocks with denial limits
- Drop codebase-upload from dhat soak test
- scheduler_create upsert via task_id; retire one-shot tasks
- Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal
- Polarity-safe syntax colors in minimal mode
- Auto mode classifies unvetted env prefixes instead of hard-prompting
- Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
grokkybara[bot] 2026-07-19 18:40:33 +01:00
commit ba76b0a683
143 changed files with 9465 additions and 3419 deletions

View file

@ -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

View file

@ -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]

View file

@ -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
}
]

View file

@ -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.

View file

@ -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
}

View file

@ -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;
}
};

View file

@ -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,

View file

@ -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::<PermissionEvent>();
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"

View file

@ -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() {

View file

@ -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<String> {
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.

View file

@ -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,

View file

@ -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<usize>,
/// 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<usize>,
/// 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<PathBuf>,
}
pub(crate) fn cap_completion_output(
output: &std::sync::Arc<str>,
cap: Option<usize>,
) -> std::sync::Arc<str> {
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()),

View file

@ -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;

View file

@ -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));
}

View file

@ -88,8 +88,7 @@ pub struct ClientHookGroup {
/// `None` (wire `null`, `""`, or `"*"`) matches every tool.
pub matcher: Option<HookMatcher>,
pub callback_ids: Vec<String>,
/// 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<std::time::Duration>,
}
@ -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<String>,
#[serde(default, rename = "continue")]
pub continue_: Option<bool>,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub additional_context: Option<String>,
}
/// 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<C
}
// Drop a non-finite/non-positive timeout (fall back to the default gate timeout) and
// cap it so a client can't make a tool hang on the gate for an unbounded time.
const MAX_HOOK_TIMEOUT_SECS: f64 = 300.0;
const MAX_HOOK_TIMEOUT_SECS: f64 = 600.0;
let timeout = group
.timeout
.filter(|s| s.is_finite() && *s > 0.0)
@ -208,6 +224,14 @@ fn parse_hook_group(event: HookEventName, value: &serde_json::Value) -> Option<C
// Match-all tokens map to no matcher (group always fires). `HookMatcher::new`
// also treats these as match-all; short-circuiting here keeps the intent explicit.
None | Some("") | Some("*") => 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");
}
}

View file

@ -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;

View file

@ -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<String>,
/// 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<String>,
/// Individual hook run results.
runs: Vec<HookRunEntryDto>,
},
/// Hooks registry changed (after reload or trust/untrust).
@ -691,6 +698,8 @@ pub enum SessionUpdate {
prompt: String,
human_schedule: String,
next_fire_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_id: Option<String>,
},
/// A scheduled task was deleted/cancelled.
ScheduledTaskDeleted { task_id: String },

View file

@ -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::<Value>(&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<String, Value>,
/// One JSON object per `updates.jsonl` line, not pre-serialized strings.
#[serde(default)]
updates: Vec<Value>,
}
/// `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<String, Value>, 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<String, Value>, key: &str, value: Option<String>) {
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<String, Value>,
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<PathBuf> {
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"
);
}
}

View file

@ -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;
}

View file

@ -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()

View file

@ -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<usize, BackendError> {
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<ContentPart>,
agent_text: String,
agent_tool_calls: Vec<ToolCall>,
in_user_turn: bool,
has_agent_content: bool,
needs_truncate: bool,
tool_args: HashMap<String, String>,
emitted_tool_results: HashSet<String>,
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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
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::<str>::from(t.text.clone()),
});
}
acp::ContentBlock::Image(img) => {
if let Some(uri) = &img.uri {
self.user_parts.push(ContentPart::Image {
url: std::sync::Arc::<str>::from(uri.clone()),
});
}
}
_ => {} // Audio, Resource, etc. not needed for chat replay
}
out
}
fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
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<ConversationItem> {
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::<str>::from(id),
name: tc.title.clone(),
arguments: std::sync::Arc::<str>::from(args),
});
Vec::new()
}
fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec<ConversationItem> {
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::<str>::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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
if !self.has_agent_content && self.agent_tool_calls.is_empty() {
return None;
}
let item = ConversationItem::Assistant(AssistantItem {
content: std::sync::Arc::<str>::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<ConversationItem> {
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::<Vec<_>>()
.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"),

View file

@ -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 {

View file

@ -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<crate::session::image_describe::ImageDescribeCache>,
/// [`SubagentSpawnInfo`] by `subagent_id`: inserted on `SubagentSpawned`, removed on
/// `SubagentFinished`.
pub(crate) subagent_spawn_info: parking_lot::Mutex<HashMap<String, SubagentSpawnInfo>>,
/// Per-subagent token state keyed by `subagent_id`; sums into
/// goal totals via [`Self::goal_tokens`].
pub(crate) subagent_token_records: parking_lot::Mutex<HashMap<String, SubagentTokenRecord>>,

View file

@ -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<Arc<RawValue>> {
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<impl Future<Output = (&'a str, ClientHookResponse, Duration)> + '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" }),
)));

View file

@ -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<crate::session::events::CancellationCategory>,
) -> Option<String> {
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]

View file

@ -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().

View file

@ -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),

View file

@ -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<result::HookRunResult>,
) -> Vec<result::HookRunResult> {
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(&registry, 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<xai_grok_tools::implementations::grok_build::task::types::ActiveSubagentSummary> {
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<StopBackgroundTask>, Vec<StopSessionCron>) {
let bridge = self.tool_bridge_handle();
let my_session = self.session_id_string();
let mut tasks: Vec<StopBackgroundTask> = 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(&registry, 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 { .. }));
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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<String> {
pub(super) fn turn_error_detail(err: &acp::Error) -> Option<String> {
err.data
.as_ref()
.and_then(crate::sampling::error::error_detail_from_data)

View file

@ -50,9 +50,9 @@ pub(crate) enum TurnOutcome {
snapshot: Box<Option<TurnDeltaSnapshot>>,
tools_called: Vec<String>,
structured_output: Option<Result<serde_json::Value, String>>,
/// 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<String>,
},
/// 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

View file

@ -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<i32> {
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(
&registry,
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) {

View file

@ -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),

View file

@ -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<xai_acp_lib::AcpClientMessage>,
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
@ -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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
@ -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::<String>));
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::<PersistenceMsg>();
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<xai_acp_lib::AcpClientMessage>| {
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::RawValue> =
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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::<xai_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
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;
}

View file

@ -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<serde_json::Value> {
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(

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -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<tokio::sync::Mutex<crate::session::mcp_servers::McpState>>,
pub managed_gateway_tool_client:
Option<xai_grok_tools::types::resources::ManagedGatewayToolClient>,
@ -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<AgentRebuildSpec> {
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![]),

View file

@ -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),

View file

@ -69,7 +69,7 @@ impl JsonlStorageAdapter {
&self,
dir: &std::path::Path,
) -> std::io::Result<Vec<ConversationItem>> {
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::<Summary>(&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<T: serde::Serialize>(&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<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
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<Summary> {
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<PersistedData> {
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::<TodoState>(&self.plan_file(info))?;
let plan_mode_state = self
@ -1289,8 +1279,9 @@ impl StorageAdapter for JsonlStorageAdapter {
) -> io::Result<super::PersistedDataLight> {
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::<TodoState>(&self.plan_file(info))?;
let plan_mode_state = self
.read_optional_json_sync::<crate::session::plan_mode::PlanModeSnapshot>(

View file

@ -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, &notify(Acp::UserMessageChunk(text("ping"))))
.await
.unwrap();
adapter
.append_update(&info, &notify(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]

View file

@ -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<u8>) -> 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<T: serde::Serialize>(items: &[T]) -> io::Result<Vec<u8>> {
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<T: serde::Serialize>(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<T: serde::Serialize>(
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.<uuid>.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<usize> {
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<ContentPart>,
agent_text: String,
agent_tool_calls: Vec<ToolCall>,
in_user_turn: bool,
has_agent_content: bool,
needs_truncate: bool,
tool_args: HashMap<String, String>,
emitted_tool_results: HashSet<String>,
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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
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::<str>::from(t.text.clone()),
});
}
acp::ContentBlock::Image(img) => {
if let Some(uri) = &img.uri {
self.user_parts.push(ContentPart::Image {
url: std::sync::Arc::<str>::from(uri.clone()),
});
}
}
_ => {} // Audio, Resource, etc. not needed for chat replay
}
out
}
fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
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<ConversationItem> {
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::<str>::from(id),
name: tc.title.clone(),
arguments: std::sync::Arc::<str>::from(args),
});
Vec::new()
}
fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec<ConversationItem> {
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::<str>::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<ConversationItem> {
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<ConversationItem> {
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<ConversationItem> {
if !self.has_agent_content && self.agent_tool_calls.is_empty() {
return None;
}
let item = ConversationItem::Assistant(AssistantItem {
content: std::sync::Arc::<str>::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<ConversationItem> {
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::<Vec<_>>()
.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<Option<Vec<acp::SessionUpdate>>> {
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);
};

View file

@ -228,9 +228,7 @@ fn read_summary(path: &Path) -> io::Result<Summary> {
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)]

View file

@ -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),
}
}

View file

@ -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();

View file

@ -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;

View file

@ -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)]

View file

@ -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<bool> {
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>) -> 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>,
) -> 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.

View file

@ -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<String> {
.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
// ============================================================================

View file

@ -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"
);
}