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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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), &[]);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue