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:
grokkybara[bot] 2026-07-30 19:07:40 +00:00
commit dd04f397b1
367 changed files with 29489 additions and 10051 deletions

View file

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

View file

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

View file

@ -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 ~050ms, 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"
);
}