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:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
22
crates/codegen/xai-grok-shell/changelogs/0.2.106.json
Normal file
22
crates/codegen/xai-grok-shell/changelogs/0.2.106.json
Normal 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
|
||||
}
|
||||
]
|
||||
12
crates/codegen/xai-grok-shell/changelogs/0.2.106.md
Normal file
12
crates/codegen/xai-grok-shell/changelogs/0.2.106.md
Normal 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.
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
309
crates/codegen/xai-grok-shell/src/extensions/session_state.rs
Normal file
309
crates/codegen/xai-grok-shell/src/extensions/session_state.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(®istry, event::HookEventName::Stop, &envelope, &ctx);
|
||||
let Ok(mut result) = tokio::time::timeout(SESSION_END_STOP_BUDGET, dispatch).await else {
|
||||
tracing::warn!("session-end stop hooks exceeded the shutdown budget; skipping");
|
||||
return;
|
||||
};
|
||||
result.results = demote_ignored_blocks(result.results);
|
||||
self.send_hook_execution("stop", None, None, &result.results)
|
||||
.await;
|
||||
self.emit_hook_executed_telemetry("stop", None, &result.results)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn list_active_subagents(
|
||||
&self,
|
||||
) -> Vec<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(®istry, event, &envelope, &ctx).await;
|
||||
}
|
||||
|
||||
if let Some(prevent) = result.prevent_continuation.take() {
|
||||
// Force-stop: skip the client gate (its signals would be discarded)
|
||||
// but still send the observe notification so client callbacks see
|
||||
// the turn end.
|
||||
self.emit_stop_results(event, prompt_id, &result.results)
|
||||
.await;
|
||||
self.notify_client_hooks(&envelope);
|
||||
self.announce_force_stop(&prevent).await;
|
||||
return StopGateDecision::AllowStop;
|
||||
}
|
||||
|
||||
// Merge file and client results and emit once: one stop gate is one
|
||||
// scrollback entry and one telemetry batch.
|
||||
let client = self.run_stop_client_hooks(&envelope).await;
|
||||
let mut all_results = std::mem::take(&mut result.results);
|
||||
all_results.extend(client.results);
|
||||
if !all_results.is_empty() {
|
||||
self.emit_stop_results(event, prompt_id, &all_results).await;
|
||||
}
|
||||
|
||||
result.blocks.extend(client.blocks);
|
||||
result.additional_context.extend(client.additional_context);
|
||||
if let Some(prevent) = client.prevent_continuation {
|
||||
self.announce_force_stop(&prevent).await;
|
||||
return StopGateDecision::AllowStop;
|
||||
}
|
||||
|
||||
if !result.wants_continuation() {
|
||||
return StopGateDecision::AllowStop;
|
||||
}
|
||||
|
||||
self.announce_keep_working(&result.blocks, &result.additional_context)
|
||||
.await;
|
||||
StopGateDecision::KeepWorking {
|
||||
feedback: format_stop_feedback(&result.blocks, &result.additional_context),
|
||||
}
|
||||
}
|
||||
|
||||
/// Annotate the scrollback when a stop gate keeps the agent working: one line
|
||||
/// per block (with `HookBlocked` telemetry), or the context lines when only
|
||||
/// `additionalContext` was returned.
|
||||
async fn announce_keep_working(
|
||||
&self,
|
||||
blocks: &[dispatcher::StopBlock],
|
||||
additional_context: &[String],
|
||||
) {
|
||||
for block in blocks {
|
||||
self.send_hook_annotation(&format!(
|
||||
"\u{21a9} Stop blocked by hook `{}`, continuing: {}",
|
||||
block.hook_name, block.reason
|
||||
))
|
||||
.await;
|
||||
xai_grok_telemetry::session_ctx::log_event(xai_grok_telemetry::events::HookBlocked {
|
||||
hook_name: block.hook_name.clone(),
|
||||
});
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
for context in additional_context {
|
||||
self.send_hook_annotation(&format!(
|
||||
"\u{21a9} Stop hook feedback, continuing: {context}"
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stop_gate_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
fn task_snapshot(
|
||||
kind: xai_grok_tools::computer::types::TaskKind,
|
||||
) -> xai_grok_tools::types::TaskSnapshot {
|
||||
xai_grok_tools::types::TaskSnapshot {
|
||||
task_id: "task-1".into(),
|
||||
command: "sandbox-exec tail -f /var/log/syslog".into(),
|
||||
display_command: Some("tail -f /var/log/syslog".into()),
|
||||
cwd: "/tmp".into(),
|
||||
start_time: std::time::SystemTime::UNIX_EPOCH,
|
||||
end_time: None,
|
||||
output: String::new(),
|
||||
output_file: std::path::PathBuf::from("/tmp/out"),
|
||||
truncated: false,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
completed: false,
|
||||
kind,
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_snapshot_maps_to_stop_entry() {
|
||||
let shell = stop_entry_from_task(&task_snapshot(
|
||||
xai_grok_tools::computer::types::TaskKind::Bash,
|
||||
));
|
||||
assert_eq!(shell.r#type, BackgroundTaskType::Shell);
|
||||
assert_eq!(shell.command.as_deref(), Some("tail -f /var/log/syslog"));
|
||||
assert!(shell.description.is_none());
|
||||
assert_eq!(shell.status, "running");
|
||||
assert!(shell.agent_type.is_none());
|
||||
|
||||
let monitor = stop_entry_from_task(&task_snapshot(
|
||||
xai_grok_tools::computer::types::TaskKind::Monitor,
|
||||
));
|
||||
assert_eq!(monitor.r#type, BackgroundTaskType::Monitor);
|
||||
assert!(monitor.command.is_none());
|
||||
assert_eq!(
|
||||
monitor.description.as_deref(),
|
||||
Some("tail -f /var/log/syslog")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_summary_maps_to_stop_entry() {
|
||||
let summary =
|
||||
xai_grok_tools::implementations::grok_build::task::types::ActiveSubagentSummary {
|
||||
subagent_id: "sub-1".into(),
|
||||
subagent_type: "explore".into(),
|
||||
description: "d".repeat(2000),
|
||||
elapsed_ms: 5,
|
||||
};
|
||||
let entry = stop_entry_from_subagent(&summary);
|
||||
assert_eq!(entry.r#type, BackgroundTaskType::Subagent);
|
||||
assert_eq!(entry.agent_type.as_deref(), Some("explore"));
|
||||
let description = entry.description.unwrap();
|
||||
assert!(description.ends_with("… [+1000 chars]"));
|
||||
assert!(entry.command.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_stop_feedback_lists_blocks_then_appends_context() {
|
||||
let block = |reason: &str| dispatcher::StopBlock {
|
||||
hook_name: "h".into(),
|
||||
reason: reason.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
format_stop_feedback(&[block("first"), block("second")], &[]),
|
||||
"Stop hook feedback:\n- first\n- second\n"
|
||||
);
|
||||
assert_eq!(
|
||||
format_stop_feedback(&[block("fix tests")], &["note".to_string()]),
|
||||
"Stop hook feedback:\n- fix tests\n\nnote"
|
||||
);
|
||||
assert_eq!(
|
||||
format_stop_feedback(&[], &["only context".to_string()]),
|
||||
"only context"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_task_maps_to_stop_cron() {
|
||||
let task =
|
||||
xai_grok_tools::implementations::grok_build::scheduler::types::ScheduledTask::new(
|
||||
300,
|
||||
"check the build".into(),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let cron = stop_cron_from_scheduled(&task);
|
||||
assert_eq!(cron.schedule, "every 5 minutes");
|
||||
assert!(cron.recurring);
|
||||
assert_eq!(cron.prompt, "check the build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demote_ignored_blocks_downgrades_only_blocked() {
|
||||
use xai_grok_hooks::result::HookRunResult;
|
||||
|
||||
let results = demote_ignored_blocks(vec![
|
||||
HookRunResult::Blocked {
|
||||
hook_name: "gate".into(),
|
||||
detail: "blocked stop: run the tests".into(),
|
||||
elapsed: std::time::Duration::from_millis(5),
|
||||
http_info: None,
|
||||
},
|
||||
HookRunResult::Failed {
|
||||
hook_name: "broken".into(),
|
||||
error: "exit code 1".into(),
|
||||
elapsed: std::time::Duration::from_millis(3),
|
||||
http_info: None,
|
||||
},
|
||||
HookRunResult::Skipped {
|
||||
hook_name: "disabled".into(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(
|
||||
matches!(&results[0], HookRunResult::Success { hook_name, .. } if hook_name == "gate"),
|
||||
"a discarded decision must read as success, got {:?}",
|
||||
results[0]
|
||||
);
|
||||
assert!(matches!(&results[1], HookRunResult::Failed { .. }));
|
||||
assert!(matches!(&results[2], HookRunResult::Skipped { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
®istry,
|
||||
xai_grok_hooks::event::HookEventName::SubagentEnd,
|
||||
&envelope,
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
{
|
||||
let mut records = self.subagent_token_records.lock();
|
||||
if let Some(rec) = records.get_mut(subagent_id) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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![]),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,43 @@ async fn test_jsonl_round_trip() {
|
|||
assert_eq!(loaded.updates.len(), 1);
|
||||
assert!(loaded.plan_state.is_some());
|
||||
}
|
||||
/// Resume from updates.jsonl alone: when chat_history.jsonl is missing, load
|
||||
/// rebuilds it from the ACP update stream (the durable source of truth).
|
||||
#[tokio::test]
|
||||
async fn load_rebuilds_chat_history_from_updates() {
|
||||
use agent_client_protocol::{
|
||||
ContentBlock, ContentChunk, SessionUpdate as Acp, TextContent,
|
||||
};
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let info = create_test_info();
|
||||
let adapter = JsonlStorageAdapter::with_root(temp_dir.path().to_path_buf());
|
||||
adapter.init_session(&info, default_model_id()).await.unwrap();
|
||||
let text = |s: &str| ContentChunk::new(
|
||||
ContentBlock::Text(TextContent::new(s.to_string())),
|
||||
);
|
||||
let notify = |u| SessionUpdate::Acp(
|
||||
Box::new(acp::SessionNotification::new(info.id.clone(), u)),
|
||||
);
|
||||
adapter
|
||||
.append_update(&info, ¬ify(Acp::UserMessageChunk(text("ping"))))
|
||||
.await
|
||||
.unwrap();
|
||||
adapter
|
||||
.append_update(&info, ¬ify(Acp::AgentMessageChunk(text("pong"))))
|
||||
.await
|
||||
.unwrap();
|
||||
let chat_path = adapter.session_dir(&info).join("chat_history.jsonl");
|
||||
assert_eq!(std::fs::metadata(& chat_path).map(| m | m.len()).unwrap_or(0), 0);
|
||||
let loaded = adapter.load_session(&info).await.unwrap();
|
||||
assert_eq!(loaded.chat_history.len(), 2, "one user + one agent conversation item");
|
||||
assert!(matches!(loaded.chat_history[0], ConversationItem::User(_)));
|
||||
assert!(matches!(loaded.chat_history[1], ConversationItem::Assistant(_)));
|
||||
let persisted = std::fs::read_to_string(&chat_path).unwrap();
|
||||
assert!(
|
||||
persisted.contains("ping") && persisted.contains("pong"),
|
||||
"rebuilt cache carries the transcript text"
|
||||
);
|
||||
}
|
||||
/// `load_session_without_updates` always defers rewind points while the full
|
||||
/// `load_session` / `load_rewind_points` still return them.
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
198
crates/codegen/xai-grok-shell/tests/test_stop_hook_e2e.rs
Normal file
198
crates/codegen/xai-grok-shell/tests/test_stop_hook_e2e.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue