Synced from monorepo

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

View file

@ -22,12 +22,10 @@
//! ```
use std::future::Future;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use serde_json::Value;
use xai_grok_test_support::env::test_env_cmd_tokio;
use xai_grok_test_support::*;
/// Run an async test body inside a `LocalSet` (required by ACP's `!Send` futures).
@ -107,25 +105,6 @@ fn inference_tool_names(server: &MockInferenceServer) -> Vec<String> {
.collect()
}
async fn run_headless_with_env(
server: &MockInferenceServer,
args: &[&str],
cwd: &Path,
env: &[(&str, &str)],
) -> HeadlessResult {
let home = tempfile::TempDir::new().expect("create temp home");
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.envs(env.iter().copied());
test_env_cmd_tokio(&mut cmd, &server.url(), home.path());
run_headless_with_cmd(cmd).await
}
// ============================================================================
// Smoke tests
// ============================================================================

View file

@ -0,0 +1,198 @@
//! Built-binary e2e smoke tests for Stop hook decision control. `#[ignore]`d by
//! default since they need the grok binary (`GROK_BINARY` or a local debug build):
//! ```bash
//! cargo test -p xai-grok-shell --test test_stop_hook_e2e -- --ignored
//! ```
use xai_grok_test_support::env::test_env_cmd_tokio;
use xai_grok_test_support::*;
/// Everything a test needs to assert on after a headless run with a Stop hook.
struct StopHookRun {
result: HeadlessResult,
server: MockInferenceServer,
state_dir: tempfile::TempDir,
_home: tempfile::TempDir,
_workdir: tempfile::TempDir,
}
impl StopHookRun {
fn invocations(&self) -> u32 {
std::fs::read_to_string(self.state_dir.path().join("count"))
.map(|s| s.trim().parse().expect("count file holds a number"))
.unwrap_or(0)
}
/// The stdin envelope the hook received on its `n`-th run (1-based).
fn hook_input(&self, n: u32) -> serde_json::Value {
let path = self.state_dir.path().join(format!("input_{n}.json"));
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("hook stdin not JSON: {e}\n{text}"))
}
/// Substring match over the serialized request JSON; keep needles free of
/// quotes and newlines.
fn some_request_contains(&self, needle: &str) -> bool {
self.server
.request_bodies()
.iter()
.any(|body| body.to_string().contains(needle))
}
}
/// Runs the built binary headless with a global Stop hook whose script body is
/// `respond`. `$n` holds the 1-based invocation number when `respond` runs.
async fn run_with_stop_hook(respond: &str) -> StopHookRun {
let home = tempfile::TempDir::new().expect("create temp home");
let state_dir = tempfile::TempDir::new().expect("create state dir");
let workdir = git_workdir();
let server = MockInferenceServer::start()
.await
.expect("start mock server");
let state = state_dir.path().display();
let script_path = home.path().join("stop_hook.sh");
// Only turn-end gate fires (`reason: "end_turn"`) are counted and
// responded to, so a session-end Stop fire (`channel_closed`/`shutdown`)
// can never skew the counts these tests assert on.
std::fs::write(
&script_path,
format!(
"#!/bin/sh\n\
cat > {state}/stdin.json\n\
grep -q '\"reason\":\"end_turn\"' {state}/stdin.json || exit 0\n\
n=$(cat {state}/count 2>/dev/null || echo 0)\n\
n=$((n+1))\n\
echo $n > {state}/count\n\
mv {state}/stdin.json {state}/input_$n.json\n\
{respond}\n"
),
)
.expect("write hook script");
let hooks_dir = home.path().join(".grok").join("hooks");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
std::fs::write(
hooks_dir.join("stop.json"),
serde_json::json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": format!("sh {}", script_path.display()),
"timeout": 30
}]
}]
}
})
.to_string(),
)
.expect("write hook config");
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(["-p", "say hello", "--yolo"])
.current_dir(workdir.path())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
test_env_cmd_tokio(&mut cmd, &server.url(), home.path());
let result = run_headless_with_cmd(cmd).await;
StopHookRun {
result,
server,
state_dir,
_home: home,
_workdir: workdir,
}
}
fn assert_success(run: &StopHookRun, label: &str) {
assert_headless_success(&run.result, label, Some(&run.server));
}
#[tokio::test]
#[ignore]
async fn stop_block_keeps_agent_working_then_allows() {
let run = run_with_stop_hook(
r#"if [ $n -eq 1 ]; then echo '{"decision":"block","reason":"finish the checklist first"}'; fi"#,
)
.await;
assert_success(&run, "stop block e2e");
assert_eq!(
run.invocations(),
2,
"gate must re-fire once after the block, then allow"
);
let first = run.hook_input(1);
assert_eq!(first["stopHookActive"], false, "first fire: no prior block");
assert!(
first["lastAssistantMessage"].is_string(),
"input carries the turn's final response, got: {first}"
);
let second = run.hook_input(2);
assert_eq!(
second["stopHookActive"], true,
"re-fire must set stopHookActive"
);
assert!(
run.some_request_contains("finish the checklist first"),
"the block reason must be fed back to the model"
);
}
#[tokio::test]
#[ignore]
async fn stop_exit_2_blocks_with_stderr_feedback() {
let run = run_with_stop_hook(
r#"if [ $n -eq 1 ]; then echo 'run the linter before finishing' >&2; exit 2; fi"#,
)
.await;
assert_success(&run, "stop exit-2 e2e");
assert_eq!(run.invocations(), 2, "exit 2 must block, then allow");
assert!(
run.some_request_contains("run the linter before finishing"),
"stderr must be fed back to the model as the block reason"
);
}
#[tokio::test]
#[ignore]
async fn stop_continue_false_overrides_block() {
let run = run_with_stop_hook(
r#"echo '{"decision":"block","reason":"never stop","continue":false,"stopReason":"budget exhausted"}'"#,
)
.await;
assert_success(&run, "stop force-stop e2e");
assert_eq!(
run.invocations(),
1,
"force-stop must end the turn without re-firing the gate"
);
assert!(
!run.some_request_contains("never stop"),
"the overridden block reason must not be fed back to the model"
);
}
#[tokio::test]
#[ignore]
async fn stop_block_loop_ends_at_continuation_cap() {
let run =
run_with_stop_hook(r#"echo '{"decision":"block","reason":"keep going forever"}'"#).await;
assert_success(&run, "stop cap e2e");
assert_eq!(
run.invocations(),
xai_grok_shell::session::MAX_STOP_HOOK_CONTINUATIONS_PER_TURN,
"the gate must stop being consulted at the continuation cap"
);
}