Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -444,6 +444,257 @@ async fn test_headless_streaming_json_output() {
|
|||
);
|
||||
}
|
||||
|
||||
/// `streaming-messages-json` emits `system`/`init`, message wrapped assistant
|
||||
/// messages, and a terminal `result`.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
async fn test_headless_streaming_messages_json_output() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
let workdir = git_workdir();
|
||||
let result = run_headless(
|
||||
&server,
|
||||
&[
|
||||
"-p",
|
||||
"say hello",
|
||||
"--yolo",
|
||||
"--output-format",
|
||||
"streaming-messages-json",
|
||||
],
|
||||
workdir.workspace(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_headless_success(
|
||||
&result,
|
||||
"grok -p --output-format streaming-messages-json",
|
||||
Some(&server),
|
||||
);
|
||||
assert_no_crashes(&result.stderr);
|
||||
|
||||
let messages: Vec<serde_json::Value> = result
|
||||
.stdout
|
||||
.lines()
|
||||
.map(|line| {
|
||||
serde_json::from_str::<serde_json::Value>(line)
|
||||
.unwrap_or_else(|e| panic!("invalid streaming-messages-json line `{line}`: {e}"))
|
||||
})
|
||||
.collect();
|
||||
fn type_of(m: &serde_json::Value) -> Option<&str> {
|
||||
m.get("type").and_then(serde_json::Value::as_str)
|
||||
}
|
||||
|
||||
let first = &messages[0];
|
||||
assert_eq!(type_of(first), Some("system"), "{messages:?}");
|
||||
assert_eq!(first["subtype"], "init", "{messages:?}");
|
||||
|
||||
let assistant = messages
|
||||
.iter()
|
||||
.find(|m| type_of(m) == Some("assistant"))
|
||||
.unwrap_or_else(|| panic!("expected an assistant message: {messages:?}"));
|
||||
assert!(assistant["message"]["content"].is_array(), "{assistant:?}");
|
||||
|
||||
let last = messages.last().expect("a result message");
|
||||
assert_eq!(type_of(last), Some("result"), "{messages:?}");
|
||||
assert_eq!(last["subtype"], "success", "{last:?}");
|
||||
assert_eq!(last["is_error"], false, "{last:?}");
|
||||
}
|
||||
|
||||
/// The Messages backend reports message id, thinking signature, verbatim stop
|
||||
/// reason, and per-response usage; all four must land on the assistant frame.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
async fn test_headless_streaming_messages_json_carries_per_response_metadata() {
|
||||
use serde_json::json;
|
||||
use xai_grok_test_support::scripted::{ScriptedResponse, SseEvent};
|
||||
|
||||
let model = "messages-compatible-model";
|
||||
let server = single_model_server(model, "messages").await;
|
||||
server.enqueue_response(
|
||||
"/v1/messages",
|
||||
ScriptedResponse::sse(vec![
|
||||
SseEvent::data(
|
||||
json!({"type":"message_start","message":{"id":"msg_e2e_9","type":"message","role":"assistant","content":[],"model":model,"stop_reason":null,"usage":{"input_tokens":12,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing it"}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-e2e-abc"}}).to_string(),
|
||||
),
|
||||
SseEvent::data(json!({"type":"content_block_stop","index":0}).to_string()),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello there"}}).to_string(),
|
||||
),
|
||||
SseEvent::data(json!({"type":"content_block_stop","index":1}).to_string()),
|
||||
SseEvent::data(
|
||||
json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7,"input_tokens":12}}).to_string(),
|
||||
),
|
||||
SseEvent::data(json!({"type":"message_stop"}).to_string()),
|
||||
]),
|
||||
);
|
||||
|
||||
let workdir = git_workdir();
|
||||
let result = run_headless(
|
||||
&server,
|
||||
&[
|
||||
"-p",
|
||||
"say hi",
|
||||
"--yolo",
|
||||
"--model",
|
||||
model,
|
||||
"--max-turns",
|
||||
"1",
|
||||
"--output-format",
|
||||
"streaming-messages-json",
|
||||
],
|
||||
workdir.workspace(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_headless_success(
|
||||
&result,
|
||||
"streaming-messages-json per-response metadata",
|
||||
Some(&server),
|
||||
);
|
||||
assert_no_crashes(&result.stderr);
|
||||
|
||||
let messages: Vec<serde_json::Value> = result
|
||||
.stdout
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
serde_json::from_str::<serde_json::Value>(line)
|
||||
.unwrap_or_else(|e| panic!("invalid streaming-messages-json line `{line}`: {e}"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let assistant = messages
|
||||
.iter()
|
||||
.find(|m| m.get("type").and_then(serde_json::Value::as_str) == Some("assistant"))
|
||||
.unwrap_or_else(|| panic!("expected an assistant message: {messages:?}"));
|
||||
let message = &assistant["message"];
|
||||
assert_eq!(
|
||||
message["id"], "msg_e2e_9",
|
||||
"real provider message id: {assistant:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
message["stop_reason"], "end_turn",
|
||||
"verbatim wire stop reason: {assistant:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
message["usage"]["input_tokens"], 12,
|
||||
"per-response usage: {assistant:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
message["usage"]["output_tokens"], 7,
|
||||
"per-response usage: {assistant:?}"
|
||||
);
|
||||
|
||||
let thinking = message["content"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("assistant content must be an array: {assistant:?}"))
|
||||
.iter()
|
||||
.find(|b| b["type"] == "thinking")
|
||||
.unwrap_or_else(|| panic!("expected a thinking block: {assistant:?}"));
|
||||
assert_eq!(
|
||||
thinking["signature"], "sig-e2e-abc",
|
||||
"thinking block must carry the reasoning signature: {assistant:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end pipeline: a Messages-backend turn that stops on a configured stop
|
||||
/// sequence must carry the provider's matched sequence all the way through the
|
||||
/// sampler → shell `response_completed` → `streaming-messages-json` reducer, so
|
||||
/// the flushed `assistant` frame reads `stop_reason: "stop_sequence"` with the
|
||||
/// real `message.stop_sequence`. Drives the actual wire (a scripted
|
||||
/// `message_delta`), not a hand-built reducer event.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
async fn test_headless_streaming_messages_json_carries_stop_sequence() {
|
||||
use serde_json::json;
|
||||
use xai_grok_test_support::scripted::{ScriptedResponse, SseEvent};
|
||||
|
||||
let model = "messages-compatible-model";
|
||||
let server = single_model_server(model, "messages").await;
|
||||
server.enqueue_response(
|
||||
"/v1/messages",
|
||||
ScriptedResponse::sse(vec![
|
||||
SseEvent::data(
|
||||
json!({"type":"message_start","message":{"id":"msg_stop_seq","type":"message","role":"assistant","content":[],"model":model,"stop_reason":null,"usage":{"input_tokens":8,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}).to_string(),
|
||||
),
|
||||
SseEvent::data(
|
||||
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"stopping here"}}).to_string(),
|
||||
),
|
||||
SseEvent::data(json!({"type":"content_block_stop","index":0}).to_string()),
|
||||
// The matched stop sequence rides the terminal `message_delta`.
|
||||
SseEvent::data(
|
||||
json!({"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":"<END>"},"usage":{"output_tokens":3,"input_tokens":8}}).to_string(),
|
||||
),
|
||||
SseEvent::data(json!({"type":"message_stop"}).to_string()),
|
||||
]),
|
||||
);
|
||||
|
||||
let workdir = git_workdir();
|
||||
let result = run_headless(
|
||||
&server,
|
||||
&[
|
||||
"-p",
|
||||
"emit the stop token",
|
||||
"--yolo",
|
||||
"--model",
|
||||
model,
|
||||
"--max-turns",
|
||||
"1",
|
||||
"--output-format",
|
||||
"streaming-messages-json",
|
||||
],
|
||||
workdir.workspace(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_headless_success(
|
||||
&result,
|
||||
"streaming-messages-json stop_sequence",
|
||||
Some(&server),
|
||||
);
|
||||
assert_no_crashes(&result.stderr);
|
||||
|
||||
let messages: Vec<serde_json::Value> = result
|
||||
.stdout
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
serde_json::from_str::<serde_json::Value>(line)
|
||||
.unwrap_or_else(|e| panic!("invalid streaming-messages-json line `{line}`: {e}"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let assistant = messages
|
||||
.iter()
|
||||
.find(|m| m.get("type").and_then(serde_json::Value::as_str) == Some("assistant"))
|
||||
.unwrap_or_else(|| panic!("expected an assistant message: {messages:?}"));
|
||||
let message = &assistant["message"];
|
||||
assert_eq!(
|
||||
message["stop_reason"], "stop_sequence",
|
||||
"verbatim stop reason: {assistant:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
message["stop_sequence"], "<END>",
|
||||
"matched stop sequence carried end-to-end: {assistant:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
async fn test_headless_json_reports_server_cost() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@
|
|||
|
||||
#![cfg(unix)]
|
||||
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
#[global_allocator]
|
||||
static DHAT_ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Warmup so the window measures steady state, not first-session cost.
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
const HEAP_WARMUP_CYCLES: u64 = 2;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -71,6 +79,24 @@ async fn rpc(client: &mut LeaderClient, payload: String, id: u64, what: &str) ->
|
|||
}
|
||||
}
|
||||
|
||||
/// Per-registry entry counts. `x.ai/debug/agent` answers under the extension
|
||||
/// envelope's own `result`, nested inside the JSON-RPC `result`.
|
||||
async fn registry_counts(client: &mut LeaderClient, id: u64) -> serde_json::Value {
|
||||
let resp = rpc(
|
||||
client,
|
||||
format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"_x.ai/debug/agent","params":{{}}}}"#),
|
||||
id,
|
||||
"x.ai/debug/agent",
|
||||
)
|
||||
.await;
|
||||
let counts = resp["result"]["result"]["registries"].clone();
|
||||
assert!(
|
||||
counts.is_object(),
|
||||
"x.ai/debug/agent returned no registries: {resp}"
|
||||
);
|
||||
counts
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "leader soak; run with --ignored (LEADER_SOAK_SECS bounds the duration)"]
|
||||
async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
|
|
@ -172,11 +198,14 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
|||
eprintln!(
|
||||
"[soak] budgets: {soak_secs}s, rss {max_growth_mb} MB, threads {max_thread_growth}"
|
||||
);
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
let mut heap_window: Option<(dhat::Profiler, dhat::HeapStats, u64)> = None;
|
||||
let rss_before = ResourceSnapshot::capture();
|
||||
let soak_deadline = tokio::time::Instant::now() + Duration::from_secs(soak_secs);
|
||||
let workdir_str = workdir.path().to_string_lossy().to_string();
|
||||
let mut cycles: u64 = 0;
|
||||
let mut turns: u64 = 0;
|
||||
let mut baseline: Option<serde_json::Value> = None;
|
||||
|
||||
// Each cycle: 10 fresh clients, 2 sessions each, one scripted
|
||||
// turn per session, then all disconnect.
|
||||
|
|
@ -252,6 +281,34 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
|||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
// An entry that never drains names itself here, one cycle
|
||||
// after it leaks, while memory is still within its budget.
|
||||
let counts = registry_counts(&mut bootstrap, 1000 + cycles).await;
|
||||
assert_eq!(
|
||||
counts["sessions"], 0,
|
||||
"cycle {cycles}: sessions outlived their close: {counts}"
|
||||
);
|
||||
match baseline.as_ref() {
|
||||
None => baseline = Some(counts),
|
||||
Some(first) => assert_eq!(
|
||||
&counts, first,
|
||||
"cycle {cycles}: registry counts left their baseline"
|
||||
),
|
||||
}
|
||||
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
if cycles == HEAP_WARMUP_CYCLES {
|
||||
let profiler = dhat::Profiler::builder()
|
||||
// The 10-frame default never reaches our own code.
|
||||
.trim_backtraces(Some(48))
|
||||
.file_name(
|
||||
std::env::var("LEADER_SOAK_DHAT_OUT")
|
||||
.unwrap_or_else(|_| "dhat-leader-soak.json".to_string()),
|
||||
)
|
||||
.build();
|
||||
heap_window = Some((profiler, dhat::HeapStats::get(), cycles));
|
||||
}
|
||||
|
||||
// Linear in cycles is a leak; flattening is the allocator.
|
||||
if let Some(rss) = ResourceSnapshot::capture().rss {
|
||||
eprintln!(
|
||||
|
|
@ -259,28 +316,29 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
|||
rss as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
}
|
||||
if cycles == 1 {
|
||||
let snap = rpc(
|
||||
&mut bootstrap,
|
||||
r#"{"jsonrpc":"2.0","id":901,"method":"_x.ai/debug/agent","params":{}}"#
|
||||
.to_string(),
|
||||
901,
|
||||
"x.ai/debug/agent",
|
||||
)
|
||||
.await;
|
||||
eprintln!("[soak] registries after cycle 1: {}", snap["result"]["registries"]);
|
||||
}
|
||||
}
|
||||
|
||||
let snap = rpc(
|
||||
&mut bootstrap,
|
||||
r#"{"jsonrpc":"2.0","id":902,"method":"_x.ai/debug/agent","params":{}}"#
|
||||
.to_string(),
|
||||
902,
|
||||
"x.ai/debug/agent",
|
||||
)
|
||||
.await;
|
||||
eprintln!("[soak] registries at end: {}", snap["result"]["registries"]);
|
||||
// Retained heap is a leak; retained pages alone are the allocator.
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
if let Some((profiler, before, start_cycle)) = heap_window.take() {
|
||||
let after = dhat::HeapStats::get();
|
||||
drop(profiler);
|
||||
let measured = cycles.saturating_sub(start_cycle).max(1);
|
||||
let net_bytes = after.curr_bytes as i64 - before.curr_bytes as i64;
|
||||
let net_blocks = after.curr_blocks as i64 - before.curr_blocks as i64;
|
||||
let per_cycle = net_bytes / measured as i64;
|
||||
eprintln!(
|
||||
"[soak] heap over {measured} cycles: {net_bytes} bytes, {net_blocks} blocks \
|
||||
({:.2} MB per cycle)",
|
||||
net_bytes as f64 / measured as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let max_per_cycle = env_u64("LEADER_SOAK_MAX_HEAP_BYTES_PER_CYCLE", 4 << 20) as i64;
|
||||
assert!(
|
||||
per_cycle <= max_per_cycle,
|
||||
"leader retained {per_cycle} heap bytes per cycle (bound {max_per_cycle})"
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("[soak] {cycles} cycles, {turns} turns in {soak_secs}s budget");
|
||||
assert!(cycles > 0, "soak budget too small to complete one cycle");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
//! Smoke: dispatch wall-clock tool duration (not post-flight ~0ms).
|
||||
//!
|
||||
//! Scripts a model turn that calls `run_terminal_command` with `sleep 2`,
|
||||
//! runs headless against the mock inference server, then asserts
|
||||
//! `events.jsonl` has a `tool_completed` with multi-second `duration_ms`
|
||||
//! and a non-empty `tool_call_id`.
|
||||
//!
|
||||
//! `#[ignore]` (needs a built binary). Run locally (auto-builds the pager):
|
||||
//! ```bash
|
||||
//! cargo test -p xai-grok-shell --test test_tool_dispatch_duration_smoke -- --ignored
|
||||
//! ```
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use xai_grok_test_support::sse::{
|
||||
chat_completions_reasoning_then_tool_call_events, responses_api_reasoning_then_tool_call_events,
|
||||
};
|
||||
use xai_grok_test_support::*;
|
||||
|
||||
const CALL_ID: &str = "call_sleep_timing";
|
||||
const SLEEP_SECS: u64 = 2;
|
||||
|
||||
fn enqueue_sleep_tool_turn(server: &MockInferenceServer) {
|
||||
let args = serde_json::json!({
|
||||
"command": format!("sleep {SLEEP_SECS}"),
|
||||
"description": "timing smoke sleep",
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Both backends: mock may pick either depending on model/settings.
|
||||
server.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_reasoning_then_tool_call_events(
|
||||
"running a short sleep",
|
||||
CALL_ID,
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
"test-model",
|
||||
)),
|
||||
);
|
||||
server.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_reasoning_then_tool_call_events(
|
||||
"running a short sleep",
|
||||
CALL_ID,
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
"test-model",
|
||||
)),
|
||||
);
|
||||
// After tool result, model finishes with plain text.
|
||||
server.set_response("slept");
|
||||
}
|
||||
|
||||
/// Shell `tool_completed` for this call id. Workspace rows share the id and can
|
||||
/// also be multi-second (hub hop), so require omitted `source`.
|
||||
fn find_tool_completed(events_jsonl: &str, call_id: &str) -> Option<serde_json::Value> {
|
||||
events_jsonl.lines().find_map(|line| {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
let is_match = v.get("type").and_then(|t| t.as_str()) == Some("tool_completed")
|
||||
&& v.get("tool_call_id").and_then(|t| t.as_str()) == Some(call_id)
|
||||
&& v.get("source").is_none();
|
||||
is_match.then_some(v)
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_events_jsonl(root: &Path) -> Vec<(std::path::PathBuf, String)> {
|
||||
let mut out = Vec::new();
|
||||
fn walk(dir: &Path, out: &mut Vec<(std::path::PathBuf, String)>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if path.file_name().and_then(|n| n.to_str()) == Some("events.jsonl")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push((path, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-built binary; run with --ignored
|
||||
async fn sleep_tool_records_multi_second_dispatch_duration() {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
enqueue_sleep_tool_turn(&server);
|
||||
|
||||
let sandbox = TestSandbox::builder().mock_url(server.url()).git().build();
|
||||
|
||||
let mut cmd = tokio::process::Command::new(grok_binary());
|
||||
cmd.args(["-p", "please sleep two seconds", "--yolo"])
|
||||
.current_dir(sandbox.workspace());
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = run_headless_in_sandbox_borrowed(cmd, &sandbox).await;
|
||||
let wall = started.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"headless elapsed={wall:?} timed_out={} status={:?}\nstderr_tail:\n{}",
|
||||
result.timed_out,
|
||||
result.status.code(),
|
||||
stderr_tail(&result.stderr, 2500)
|
||||
);
|
||||
|
||||
assert_headless_success(&result, "grok -p sleep timing smoke", Some(&server));
|
||||
assert_no_crashes(&result.stderr);
|
||||
|
||||
// Session artifacts live under the sandbox GROK_HOME.
|
||||
let home = sandbox.grok_home();
|
||||
let events_files = collect_events_jsonl(home);
|
||||
assert!(
|
||||
!events_files.is_empty(),
|
||||
"no events.jsonl under GROK_HOME {}\nstderr:\n{}",
|
||||
home.display(),
|
||||
stderr_tail(&result.stderr, 2000)
|
||||
);
|
||||
|
||||
let (path, ev) = events_files
|
||||
.iter()
|
||||
.find_map(|(path, text)| find_tool_completed(text, CALL_ID).map(|ev| (path, ev)))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no shell tool_completed (source omitted) for {CALL_ID} under {}\nfiles: {:?}",
|
||||
home.display(),
|
||||
events_files
|
||||
.iter()
|
||||
.map(|(p, t)| (p.display().to_string(), t.lines().count()))
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
|
||||
let duration_ms = ev
|
||||
.get("duration_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| panic!("duration_ms missing in {ev} ({})", path.display()));
|
||||
let tool_name = ev.get("tool_name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
|
||||
eprintln!(
|
||||
"tool_completed path={} tool_name={tool_name} duration_ms={duration_ms}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
// A post-flight clock reports single-digit ms for this call; 1.5s floor
|
||||
// absorbs CI jitter, the ceiling only catches a nonsense value.
|
||||
assert!(
|
||||
duration_ms >= 1_500,
|
||||
"expected duration_ms >= 1500 for sleep {SLEEP_SECS}s, got {duration_ms}ms \
|
||||
(if ~0–50ms, dispatch timing regressed to post-flight clock)"
|
||||
);
|
||||
assert!(
|
||||
duration_ms < 30_000,
|
||||
"duration_ms={duration_ms} implausibly high for sleep {SLEEP_SECS}"
|
||||
);
|
||||
|
||||
// Sanity: the sleep really happened inside this run.
|
||||
assert!(
|
||||
wall >= Duration::from_millis(1_500),
|
||||
"headless wall {wall:?} shorter than sleep — tool may not have run"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue