Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -17,12 +17,86 @@ use xai_grok_test_support::MockInferenceServer;
pub use xai_grok_test_support::mock_server::LogEntry;
pub use xai_grok_test_support::mock_server::MockModelEntry as MockModel;
pub use xai_grok_test_support::mock_server::StorageUpload;
// SSE event builders for `enqueue_response` scripts (reasoning turns etc.).
pub use xai_grok_test_support::sse;
pub use xai_grok_test_support::{
InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, ScriptedResponse, SseEvent,
};
/// The endpoint-specific expectations for one logical foreground agent turn.
#[must_use = "keep the handle to synchronize or verify the logical agent turn"]
pub struct AgentTurnExpectation {
expectations: [InferenceExpectation; 2],
}
impl AgentTurnExpectation {
/// Wait until either supported pager backend claims the turn.
pub async fn wait_received(&mut self) {
let [responses, chat_completions] = &mut self.expectations;
tokio::select! {
_ = responses.wait_received() => {}
_ = chat_completions.wait_received() => {}
}
}
/// Wait until the active backend reaches this turn's terminal barrier.
pub async fn wait_blocked(&mut self) {
let [responses, chat_completions] = &mut self.expectations;
tokio::select! {
_ = responses.wait_blocked() => {}
_ = chat_completions.wait_blocked() => {}
}
}
/// Release both endpoint variants of this turn's terminal barrier.
pub fn release(&self) {
for expectation in &self.expectations {
expectation.release();
}
}
/// Wait until the active backend completes this turn.
pub async fn wait_satisfied(&mut self) {
let [responses, chat_completions] = &mut self.expectations;
tokio::select! {
_ = responses.wait_satisfied() => {}
_ = chat_completions.wait_satisfied() => {}
}
}
/// Whether either supported endpoint completed this logical turn.
pub fn is_satisfied(&self) -> bool {
self.expectations
.iter()
.any(InferenceExpectation::is_satisfied)
}
/// Panic unless one supported backend completed this turn.
pub fn assert_satisfied(&self) {
assert!(
self.is_satisfied(),
"logical agent turn was not satisfied: {}",
self.diagnostic(),
);
}
/// Describe both endpoint variants for aggregated failure output.
pub fn diagnostic(&self) -> String {
format!(
"{}; {}",
self.expectations[0].diagnostic(),
self.expectations[1].diagnostic(),
)
}
/// Endpoint expectations that were not claimed by the active backend.
pub fn unsatisfied_diagnostics(&self) -> impl Iterator<Item = String> + '_ {
self.expectations
.iter()
.filter(|expectation| !expectation.is_satisfied())
.map(InferenceExpectation::diagnostic)
}
}
/// Drives content into the pager by serving a mock inference endpoint that
/// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`.
///
@ -100,8 +174,6 @@ impl ContentController {
("GROK_TRACE_UPLOAD".into(), "false".into()),
// Keep unrelated autocomplete work out of PTY timing assertions.
("GROK_PROMPT_SUGGESTIONS".into(), "false".into()),
// Compatibility set_turns remains request-FIFO, so retries stay off.
("GROK_MAX_RETRIES".into(), "0".into()),
]
}
@ -111,9 +183,9 @@ impl ContentController {
self.server.set_response(text);
}
/// Queue a byte-exact scripted response for the next request on `path`
/// (e.g. `"/v1/responses"`). Consumed FIFO per path; falls back to the
/// active fixed/echo mode when the queue is empty.
/// Queue a compatibility response for the next request on `path`.
/// Inference callers should use a matched expectation; this remains for
/// non-inference one-shots such as `"/v1/settings"`.
pub fn enqueue_response(&self, path: impl Into<String>, response: ScriptedResponse) {
self.server.enqueue_response(path, response);
}
@ -130,23 +202,6 @@ impl ContentController {
self.server.set_chunk_delay(delay);
}
/// Hold foreground completions until [`release_agent_completions`].
/// Prefer [`expect_response_blocked`] for new tests.
///
/// [`release_agent_completions`]: Self::release_agent_completions
/// [`expect_response_blocked`]: Self::expect_response_blocked
pub fn hold_agent_completions(&self) {
self.server.hold_agent_completions();
}
/// Release a hold set by [`hold_agent_completions`], letting the gated
/// turn complete.
///
/// [`hold_agent_completions`]: Self::hold_agent_completions
pub fn release_agent_completions(&self) {
self.server.release_agent_completions();
}
/// Register a named response for the next matching inference request.
pub fn expect_response(
&self,
@ -167,9 +222,81 @@ impl ContentController {
self.server.expect_response_blocked(name, matcher, response)
}
/// Queue one compatibility response per foreground turn.
pub fn set_turns(&self, turns: impl IntoIterator<Item = String>) {
self.server.set_agent_turns(turns);
/// Register the same named foreground text turn for both pager backends.
pub fn expect_agent_turn(
&self,
name: impl AsRef<str>,
text: impl AsRef<str>,
) -> AgentTurnExpectation {
self.expect_agent_turn_with_responses(
name,
ScriptedResponse::sse(sse::responses_api_script_exact(text.as_ref(), "test-model")),
ScriptedResponse::sse(sse::chat_completion_script_exact(
text.as_ref(),
"test-model",
)),
)
}
/// Register the same named foreground text turn for both pager backends,
/// blocked immediately before its terminal event.
pub fn expect_agent_turn_blocked(
&self,
name: impl AsRef<str>,
text: impl AsRef<str>,
) -> AgentTurnExpectation {
self.expect_agent_turn_with_responses_inner(
name.as_ref(),
ScriptedResponse::sse(sse::responses_api_script_exact(text.as_ref(), "test-model")),
ScriptedResponse::sse(sse::chat_completion_script_exact(
text.as_ref(),
"test-model",
)),
true,
)
}
/// Register endpoint-specific responses for one logical foreground turn.
pub fn expect_agent_turn_with_responses(
&self,
name: impl AsRef<str>,
responses: ScriptedResponse,
chat_completions: ScriptedResponse,
) -> AgentTurnExpectation {
self.expect_agent_turn_with_responses_inner(
name.as_ref(),
responses,
chat_completions,
false,
)
}
fn expect_agent_turn_with_responses_inner(
&self,
name: &str,
responses: ScriptedResponse,
chat_completions: ScriptedResponse,
blocked: bool,
) -> AgentTurnExpectation {
let register = |endpoint, suffix: &str, response| {
let name = format!("{name} ({suffix})");
let matcher = InferenceRequestMatcher::foreground(endpoint);
if blocked {
self.expect_response_blocked(name, matcher, response)
} else {
self.expect_response(name, matcher, response)
}
};
AgentTurnExpectation {
expectations: [
register(InferenceEndpoint::Responses, "responses", responses),
register(
InferenceEndpoint::ChatCompletions,
"chat completions",
chat_completions,
),
],
}
}
/// Number of inference requests the pager has made so far.
@ -217,6 +344,43 @@ fn default_response_text() -> String {
mod tests {
use super::*;
const EXPECTATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
fn foreground_request(endpoint: InferenceEndpoint) -> (&'static str, serde_json::Value) {
match endpoint {
InferenceEndpoint::ChatCompletions => (
"/chat/completions",
serde_json::json!({
"model": "test-model",
"messages": [{ "role": "user", "content": "hello" }]
}),
),
InferenceEndpoint::Responses => (
"/responses",
serde_json::json!({
"model": "test-model",
"input": [{ "role": "user", "content": "hello" }]
}),
),
InferenceEndpoint::Messages => unreachable!("pager helper supports two backends"),
}
}
async fn read_foreground(url: String, endpoint: InferenceEndpoint) -> String {
let (path, body) = foreground_request(endpoint);
reqwest::Client::new()
.post(format!("{url}{path}"))
.header("x-grok-turn-idx", "1")
.header("x-grok-req-id", format!("direct-{endpoint:?}"))
.json(&body)
.send()
.await
.expect("send direct foreground request")
.text()
.await
.expect("read direct foreground response")
}
/// The pre-delegation mock always served 200 `{"allow_access": true}`;
/// the shared server defaults to 404-until-set. A 404 strands the pager
/// on the SuperGrok upsell screen and breaks every PTY test.
@ -270,6 +434,111 @@ mod tests {
assert!(content.has_chat_completion());
}
#[tokio::test]
async fn logical_turn_accepts_either_supported_endpoint() {
for endpoint in [
InferenceEndpoint::ChatCompletions,
InferenceEndpoint::Responses,
] {
let content = ContentController::start().await.unwrap();
let mut turn = content.expect_agent_turn("either endpoint", "MATCHED_TURN");
let request = tokio::spawn(read_foreground(content.url(), endpoint));
tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_received())
.await
.expect("logical turn received through active endpoint");
let body = tokio::time::timeout(EXPECTATION_TIMEOUT, request)
.await
.expect("active endpoint response completed")
.expect("direct request task completed");
tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_satisfied())
.await
.expect("logical turn satisfied through active endpoint");
assert!(body.contains("MATCHED_TURN"), "body: {body}");
assert!(turn.is_satisfied());
turn.assert_satisfied();
let diagnostics: Vec<_> = turn.unsatisfied_diagnostics().collect();
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
let unused = match endpoint {
InferenceEndpoint::Responses => "chat completions",
InferenceEndpoint::ChatCompletions => "responses",
InferenceEndpoint::Messages => unreachable!(),
};
assert!(diagnostics[0].contains(unused), "{diagnostics:#?}");
assert!(diagnostics[0].contains("Pending"), "{diagnostics:#?}");
}
}
#[tokio::test]
async fn logical_blocked_turn_observes_release_and_satisfaction() {
for endpoint in [
InferenceEndpoint::ChatCompletions,
InferenceEndpoint::Responses,
] {
let content = ContentController::start().await.unwrap();
let mut turn =
content.expect_agent_turn_blocked("blocked turn", "BLOCKED_MATCHED_TURN");
let mut request = tokio::spawn(read_foreground(content.url(), endpoint));
tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_received())
.await
.expect("logical blocked turn received");
tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_blocked())
.await
.expect("logical blocked turn reached terminal barrier");
assert!(!turn.is_satisfied());
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut request)
.await
.is_err(),
"response completed before release"
);
turn.release();
let body = tokio::time::timeout(EXPECTATION_TIMEOUT, request)
.await
.expect("response completed after release")
.expect("direct request task completed");
tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_satisfied())
.await
.expect("logical blocked turn satisfied after release");
assert!(body.contains("BLOCKED_MATCHED_TURN"), "body: {body}");
turn.assert_satisfied();
}
}
#[tokio::test]
async fn unused_logical_turn_reports_both_endpoint_variants_and_fails_contract() {
let content = ContentController::start().await.unwrap();
let turn = content.expect_agent_turn("unused logical turn", "unused");
assert!(!turn.is_satisfied());
let diagnostics: Vec<_> = turn.unsatisfied_diagnostics().collect();
assert_eq!(diagnostics.len(), 2, "{diagnostics:#?}");
assert!(
diagnostics.iter().any(|d| d.contains("responses")),
"{diagnostics:#?}"
);
assert!(
diagnostics.iter().any(|d| d.contains("chat completions")),
"{diagnostics:#?}"
);
assert!(diagnostics.iter().all(|d| d.contains("Pending")));
let aggregate = turn.diagnostic();
assert!(aggregate.contains("responses"), "{aggregate}");
assert!(aggregate.contains("chat completions"), "{aggregate}");
let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
turn.assert_satisfied();
}));
assert!(
panic.is_err(),
"unused logical turn must fail one-of-two contract"
);
}
/// `env_for_pager` keeps the exact sandbox + endpoint env contract the
/// pager spawn path depends on.
#[tokio::test]
@ -294,7 +563,7 @@ mod tests {
assert_eq!(get("GROK_FEEDBACK_ENABLED").as_deref(), Some("false"));
assert_eq!(get("GROK_TRACE_UPLOAD").as_deref(), Some("false"));
assert_eq!(get("GROK_PROMPT_SUGGESTIONS").as_deref(), Some("false"));
assert_eq!(get("GROK_MAX_RETRIES").as_deref(), Some("0"));
assert_eq!(env.len(), 10, "env list must not silently grow or shrink");
assert_eq!(get("GROK_MAX_RETRIES"), None);
assert_eq!(env.len(), 9, "env list must not silently grow or shrink");
}
}

View file

@ -36,8 +36,8 @@ pub mod scroll_matrix;
pub mod timing;
pub use content::{
ContentController, InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, MockModel,
ScriptedResponse, SseEvent, sse,
AgentTurnExpectation, ContentController, InferenceEndpoint, InferenceExpectation,
InferenceRequestMatcher, MockModel, ScriptedResponse, SseEvent, sse,
};
pub use env::pager_binary;
pub use flows::{

View file

@ -53,11 +53,12 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
.context("start ContentController")?;
// Gate turn 1's terminal event so the queue + empty-Enter provably land
// mid-turn — a paced-chunk window races turn end on slow (remote) workers.
content.hold_agent_completions();
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO reply to the promoted follow-up.".to_owned(),
]);
let mut turn_one = content
.expect_agent_turn_blocked("running turn before send-now", slow_turn_text("TURNONE"));
let mut turn_two = content.expect_agent_turn(
"promoted queued follow-up",
"TURNTWO reply to the promoted follow-up.",
);
let binary = pager_binary().context("resolve pager binary")?;
let mut harness =
@ -70,6 +71,9 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.context("turn 1 streaming")?;
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.context("turn 1 completion-barrier timeout")?;
harness
.inject_keys(b"please also check the logs\r")
@ -81,8 +85,7 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
harness.inject_keys(b"\r").context("empty Enter send-now")?;
// Cancel-and-send: the shell cancels turn 1 (its held completion is
// irrelevant — the abort wins) and promotes the row to run as turn 2.
// Release the gate so any completion race resolves rather than hangs.
content.release_agent_completions();
turn_one.release();
// The promoted row renders as a standard user prompt block (" " prefix
// distinguishes the committed block from the prefix-less queue row) with
// the new turn's reply below it.
@ -95,6 +98,9 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
harness
.wait_for_text("TURNTWO", Duration::from_secs(40))
.context("promoted turn reply")?;
tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied())
.await
.context("promoted turn expectation timeout")?;
// A send-now cancel is silent: no "Turn cancelled by user" marker may
// appear between the partial turn-1 output and the promoted prompt.

View file

@ -41,13 +41,14 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
let content = ContentController::start()
.await
.context("start ContentController")?;
// One response per agent turn (FIFO, 2+-tool requests only — aux requests
// never steal one). Turn 1 is consumed by the first pager; turn 2 by the
// implement turn the shell starts after approval.
content.set_turns([
let mut setup_turn = content.expect_agent_turn(
"initial plan-drafting turn",
format!("{SETUP_SENTINEL}: drafted a plan for the user to review."),
);
let mut implement_turn = content.expect_agent_turn(
"implementation after approval",
format!("{IMPLEMENT_SENTINEL}: implementing the approved plan."),
]);
);
let project = tempfile::tempdir().context("project dir")?;
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
@ -69,6 +70,9 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
first
.wait_for_text(SETUP_SENTINEL, Duration::from_secs(30))
.context("setup turn rendered")?;
tokio::time::timeout(Duration::from_secs(10), setup_turn.wait_satisfied())
.await
.context("setup turn expectation timeout")?;
// Quit and reap BEFORE seeding so the still-live shell cannot re-persist
// and clobber the seeded state.
@ -121,6 +125,9 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
resumed
.wait_for_text(IMPLEMENT_SENTINEL, Duration::from_secs(30))
.context("approve must leave plan mode and start the implement turn")?;
tokio::time::timeout(Duration::from_secs(10), implement_turn.wait_satisfied())
.await
.context("implement turn expectation timeout")?;
resumed.quit().context("quit resumed pager")?;
Ok(())

View file

@ -7,12 +7,14 @@
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use crate::{ContentController, PtyHarness, StyledLine, pager_binary, parse_keys};
use crate::{
AgentTurnExpectation, ContentController, PtyHarness, StyledLine, pager_binary, parse_keys,
};
const SGR_LEFT_BUTTON: u16 = 0;
const SGR_MIDDLE_BUTTON: u16 = 1;
@ -26,6 +28,7 @@ pub const SGR_SCROLL_DOWN: u16 = 65;
const DEFAULT_ROWS: u16 = 50;
const DEFAULT_COLS: u16 = 120;
const DEFAULT_WAIT_TIMEOUT_MS: u64 = 15_000;
const EXPECTATION_SETTLE_TIMEOUT: Duration = Duration::from_secs(10);
/// Declarative scenario consumed by [`ScriptedScenarioRunner`].
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -172,11 +175,12 @@ pub struct WorkspaceConfig {
pub struct MockConfig {
#[serde(default = "default_mock_response")]
pub response: String,
/// Optional per-agent-turn responses, consumed FIFO (one per real agent
/// turn; aux requests don't consume one — see
/// `MockInferenceServer::set_agent_turns`). Lets a scenario give each
/// turn a distinct sentinel, e.g. to prove a transcript tail was
/// truncated and re-generated. Falls back to `response` when exhausted.
/// Required per-agent-turn responses, registered as ordered foreground
/// expectations on both supported pager inference backends. Every listed
/// turn must be satisfied before the runner reports success. Lets a
/// scenario give each turn a distinct sentinel, e.g. to prove a transcript
/// tail was truncated and re-generated. Falls back to `response` when
/// exhausted.
#[serde(default)]
pub turns: Vec<String>,
#[serde(default)]
@ -531,9 +535,15 @@ impl ScriptedScenarioRunner {
.await
.context("start mock content")?;
content.set_response(&scenario.mock.response);
if !scenario.mock.turns.is_empty() {
content.set_turns(scenario.mock.turns.iter().cloned());
}
let turn_expectations: Vec<_> = scenario
.mock
.turns
.iter()
.enumerate()
.map(|(index, turn)| {
content.expect_agent_turn(format!("scenario turn {}", index + 1), turn)
})
.collect();
if let Some(config_toml) = &scenario.environment.config_toml {
let grok_home = content.home().join(".grok");
@ -636,6 +646,34 @@ impl ScriptedScenarioRunner {
report.status = ScriptedRunStatus::Failed;
}
if report.status == ScriptedRunStatus::Running {
let settle_deadline = Instant::now() + EXPECTATION_SETTLE_TIMEOUT;
while turn_expectations
.iter()
.any(|expectation| !expectation.is_satisfied())
&& Instant::now() < settle_deadline
{
harness.update(Duration::from_millis(100));
}
}
let unsatisfied_turns: Vec<_> = turn_expectations
.iter()
.filter(|expectation| !expectation.is_satisfied())
.map(AgentTurnExpectation::diagnostic)
.collect();
if !unsatisfied_turns.is_empty() {
report.bugs.push(BugFinding {
step: scenario.steps.len(),
severity: BugSeverity::Bug,
message: format!(
"required mock.turns expectations were not satisfied:\n- {}",
unsatisfied_turns.join("\n- ")
),
screen_text: harness.screen_contents(),
});
report.status = ScriptedRunStatus::Failed;
}
let _ = harness.quit();
if report.status == ScriptedRunStatus::Running {
report.status = ScriptedRunStatus::Passed;

View file

@ -165,9 +165,7 @@ async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Res
let mut env: Vec<(&str, &str)> = cell.env.to_vec();
env.push(("GROK_SCROLL_LOG", log_value));
// Live bindings on purpose: `content` owns the mock server (and the
// streaming completion gate) — see the session module's footgun docs.
let (mut harness, content, baseline) =
let (mut harness, content, baseline, streaming_turn) =
spawn_marker_session(binary, cell.session, MARKER_COUNT, &env).await;
// Replay the gesture table: sleep each step's pre-delay (host-side lower
@ -194,19 +192,18 @@ async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Res
let quiet_frames = harness.frame_count();
let marker_after = topmost_visible_marker(&harness);
// Streaming teardown: the CALLER owns the gate release (session-module
// contract) — release after the gesture so the pager exits a completed
// turn, and prove the release took (the held gate is the alternative
// explanation for almost any streaming-cell wedge).
if cell.session == SessionKind::Streaming {
content.release_agent_completions();
if let Some(mut streaming_turn) = streaming_turn {
streaming_turn.release();
let deadline = Instant::now() + COMPLETION_TIMEOUT;
while harness.contains_text("Responding") {
if Instant::now() >= deadline {
bail!("teardown: turn never completed after the gate release");
bail!("teardown: turn never completed after the expectation release");
}
harness.update(Duration::from_millis(200));
}
tokio::time::timeout(COMPLETION_TIMEOUT, streaming_turn.wait_satisfied())
.await
.context("teardown: streaming expectation was not satisfied")?;
}
harness.quit().context("teardown: quit pager")?;
drop(content);
@ -397,18 +394,21 @@ mod tests {
assert!(check_quiet(0).is_pass());
assert!(check_quiet(QUIET_MAX_FRAMES).is_pass());
let result = check_quiet(QUIET_MAX_FRAMES + 1);
assert!(matches!(result, InvariantResult::Violated { ref detail }
if detail.contains("churn")));
assert!(
matches!(result, InvariantResult::Violated { ref detail } if detail.contains("churn"))
);
}
#[test]
fn screen_rejects_streaming_sessions_and_marker_loss() {
let streaming = check_screen(SessionKind::Streaming, 100, Some(100), &[]);
assert!(matches!(streaming, InvariantResult::Violated { ref detail }
if detail.contains("streaming")));
assert!(
matches!(streaming, InvariantResult::Violated { ref detail } if detail.contains("streaming"))
);
let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]);
assert!(matches!(lost, InvariantResult::Violated { ref detail }
if detail.contains("no marker")));
assert!(
matches!(lost, InvariantResult::Violated { ref detail } if detail.contains("no marker"))
);
// Empty capture ⇒ no movement expected; a matching marker passes.
assert!(check_screen(SessionKind::BottomPinned, 100, Some(100), &[]).is_pass());
let moved = check_screen(SessionKind::BottomPinned, 100, Some(97), &[]);

View file

@ -12,19 +12,16 @@
//! mock server mid-session and surfacing as a confusing 60s stream timeout
//! instead of an obvious failure.
//!
//! ## Gate-release ownership (streaming sessions)
//! ## Streaming sessions
//!
//! [`spawn_streaming_marker_session`] holds every agent completion
//! (`hold_agent_completions`) and paces deltas, so the turn provably cannot
//! finish while the gesture runs. The CALLER owns the release: call
//! `content.release_agent_completions()` after the gesture (before quitting,
//! so the pager exits a completed turn rather than an aborted stream).
//! [`spawn_streaming_marker_session`] returns a blocked turn expectation. The
//! caller releases it after the gesture and before quitting.
use std::path::Path;
use std::time::Duration;
use crate::PtyHarness;
use crate::content::ContentController;
use crate::content::{AgentTurnExpectation, ContentController};
/// Transcript state a cell's gesture starts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -127,18 +124,23 @@ pub const STREAMING_CHUNK_DELAY: Duration = Duration::from_millis(30);
/// `GROK_SCROLL_LOG` — the PTY spawn strips host-terminal identity first,
/// so injected markers always win).
///
/// Returns `(harness, controller, baseline)` where `baseline` is the
/// topmost visible marker index — see the footgun notes in the module docs
/// before binding the controller.
/// `blocked_turn` is present only for a streaming session.
pub async fn spawn_marker_session(
binary: &Path,
kind: SessionKind,
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
) -> (
PtyHarness,
ContentController,
usize,
Option<AgentTurnExpectation>,
) {
match kind {
SessionKind::Settled | SessionKind::BottomPinned => {
spawn_settled_marker_session(binary, marker_count, extra_env).await
let (harness, content, baseline) =
spawn_settled_marker_session(binary, marker_count, extra_env).await;
(harness, content, baseline, None)
}
SessionKind::Streaming => {
spawn_streaming_marker_session(
@ -232,29 +234,29 @@ pub async fn spawn_settled_marker_session(
/// [`SessionKind::Streaming`] preamble: the whole fenced marker block rides
/// the first delta (the mock splits deltas on single spaces and the block
/// contains none), the space-separated tail streams word-by-word at
/// `chunk_delay`, and the completion gate holds the turn's terminal event —
/// mid-turn by construction until the caller releases the gate (see the
/// module docs). Setup guards: transcript overflows the viewport and
/// [`STREAM_END_SENTINEL`] is not on screen (bottom-pinned follow would
/// render it if the stream had finished).
/// `chunk_delay`, and the matched expectation prevents terminal completion
/// until the caller releases it. Setup guards: transcript overflows the
/// viewport and [`STREAM_END_SENTINEL`] is not on screen.
pub async fn spawn_streaming_marker_session(
binary: &Path,
marker_count: usize,
tail_words: usize,
chunk_delay: Duration,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
) -> (
PtyHarness,
ContentController,
usize,
Option<AgentTurnExpectation>,
) {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(chunk_delay));
content.hold_agent_completions();
// set_turns (not set_response): only agent turns ride the completion
// gate; aux title/classifier requests fall through untouched.
let mut turn = marker_response(marker_count);
for i in 0..tail_words {
turn.push_str(&format!("TAIL-{i:04} "));
}
turn.push_str(STREAM_END_SENTINEL);
content.set_turns([turn]);
let turn = content.expect_agent_turn_blocked("streaming marker turn", turn);
let mut harness = spawn_pager(binary, &content, extra_env);
// The last marker rides the first delta, so this waits only for the
@ -272,7 +274,7 @@ pub async fn spawn_streaming_marker_session(
);
let baseline = assert_scrollable_baseline(&harness, "mid-stream");
(harness, content, baseline)
(harness, content, baseline, Some(turn))
}
#[cfg(test)]

View file

@ -56,9 +56,13 @@ async fn scroll_up_from_follow_bottom_then_back_down() -> Result<()> {
.context("welcome")?;
harness.inject_keys(b"scroll test\r")?;
// Follow mode pins the viewport to the bottom, so the top marker only
// flashes on-screen before scrolling above the viewport — polling for it
// races a fast stream. Wait for the bottom marker, which stays visible in
// follow mode once the response reaches it.
harness
.wait_for_text("MARKER_TOP_OF_RESPONSE", Duration::from_secs(30))
.context("top marker while streaming")?;
.wait_for_text("MARKER_BOTTOM_OF_RESPONSE", Duration::from_secs(30))
.context("response reached bottom while following")?;
// Wait for stream end. Follow mode pins the viewport to the bottom.
let settle_deadline = Instant::now() + Duration::from_secs(45);

View file

@ -22,8 +22,19 @@ use xai_grok_pager_pty_harness::scroll_matrix::{
/// across awaits; no poisoning, so one failed cell doesn't cascade).
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Per-cell captures land here. Prefer Bazel's `TEST_TMPDIR` (unique and
/// isolated per test action) over the shared system temp dir: this target is
/// `tags = ["local"]`, so concurrent executions on a CI host would otherwise
/// share a stable `/tmp/scroll-matrix-curated/<cell_id>.jsonl` path — a
/// second run's stale-capture `remove_file` (and its pager's `GROK_SCROLL_LOG`
/// writer) then corrupts the first run's in-flight capture, surfacing as a
/// `parse capture: No such file or directory` or a mid-record parse error.
/// Falls back to the system temp dir for plain `cargo test`.
fn artifacts_dir() -> PathBuf {
std::env::temp_dir().join("scroll-matrix-curated")
std::env::var_os("TEST_TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("scroll-matrix-curated")
}
/// Run one curated cell by id and return its report (panics on unknown ids