M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,65 @@
//! `pty-scenario` — scripted TUI regression runner for kigi-tui.
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::{Context, Result, bail};
use clap::Parser as ClapParser;
use kigi_pager_pty_harness::{
ScriptedRunConfig, ScriptedRunStatus, ScriptedScenario, ScriptedScenarioRunner, pager_binary,
};
#[derive(ClapParser, Debug)]
#[command(
name = "pty-scenario",
about = "Run declarative TUI regression scenarios against kigi-tui",
long_about = None,
)]
struct Cli {
/// Scenario file to run. Supports JSON, YAML, and YML.
#[arg(long, value_name = "PATH")]
scenario: PathBuf,
/// Pager binary. Defaults to PAGER_BINARY, CARGO_BIN_EXE_kigi-tui,
/// or a locally-built debug binary.
#[arg(long, value_name = "PATH")]
binary: Option<PathBuf>,
/// Directory for report.json, bugs.md, text/html/svg/styled-json captures.
#[arg(long, value_name = "DIR", default_value = "target/pty-scenarios")]
artifacts: PathBuf,
}
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> ExitCode {
match run().await {
Ok(status) => status,
Err(error) => {
eprintln!("pty-scenario failed: {error:#}");
ExitCode::from(2)
}
}
}
async fn run() -> Result<ExitCode> {
let cli = Cli::parse();
let scenario = ScriptedScenario::from_file(&cli.scenario)
.with_context(|| format!("load scenario {}", cli.scenario.display()))?;
let binary = match cli.binary {
Some(path) => path,
None => pager_binary().context("resolve pager binary")?,
};
if !binary.exists() {
bail!("pager binary does not exist: {}", binary.display());
}
let runner = ScriptedScenarioRunner::new(ScriptedRunConfig::new(binary, cli.artifacts));
let report = runner.run(&scenario).await?;
let json = serde_json::to_string_pretty(&report).context("serialize final report")?;
println!("{json}");
match report.status {
ScriptedRunStatus::Passed | ScriptedRunStatus::Skipped => Ok(ExitCode::SUCCESS),
ScriptedRunStatus::Failed | ScriptedRunStatus::Running => Ok(ExitCode::from(1)),
}
}
@@ -0,0 +1,151 @@
//! `scroll-matrix` — scroll validation matrix sweep for kigi-tui.
//!
//! Runs matrix cells (`scroll_matrix::CELLS`) against a real pager binary in
//! a PTY, prints the per-cell verdict table, writes `report.json` into the
//! artifacts dir (next to each cell's recorder capture), and exits nonzero
//! iff any cell failed or an xfail cell passed. The curated tier also runs
//! in CI as `tests/scroll_matrix_curated.rs`; this binary is the local
//! entry point for the full sweep and for one-off cell reruns (`--filter`).
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use clap::{Parser as ClapParser, ValueEnum};
use kigi_pager_pty_harness::pager_binary;
use kigi_pager_pty_harness::scroll_matrix::{
CELLS, CellReport, MatrixCell, Tier, exit_code, run_cell, summary_table, write_report_json,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum TierArg {
/// The CI subset.
Curated,
/// Every cell (curated + full-tier rows).
Full,
}
#[derive(ClapParser, Debug)]
#[command(
name = "scroll-matrix",
about = "Run the scroll validation matrix against kigi-tui",
long_about = None,
)]
struct Cli {
/// Cell tier to run.
#[arg(long, value_enum, default_value_t = TierArg::Curated)]
tier: TierArg,
/// Only run cells whose id contains this substring.
#[arg(long, value_name = "SUBSTR")]
filter: Option<String>,
/// Concurrent cells. Default 1 because gestures are host-paced: parallel
/// cells contend for CPU and stretch the inter-report sleeps, which can
/// flip Auto-mode classifications. The invariant suite is stall-safe (it
/// judges timing from the recorder's clock), so raising this stays sound
/// — it just makes captures less representative of real gesture timing.
#[arg(long, value_name = "N", default_value_t = 1)]
jobs: usize,
/// Directory for report.json and the per-cell recorder captures.
#[arg(long, value_name = "DIR", default_value = "target/scroll-matrix")]
artifacts: PathBuf,
/// Pager binary. Defaults to PAGER_BINARY, CARGO_BIN_EXE_kigi-tui,
/// or a locally-built debug binary.
#[arg(long, value_name = "PATH")]
binary: Option<PathBuf>,
}
// Multi-thread runtime required: run_cell drives its blocking cell body via
// Handle::block_on on a spawn_blocking thread (see scroll_matrix::runner).
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> ExitCode {
match run().await {
Ok(code) => code,
Err(error) => {
eprintln!("scroll-matrix failed: {error:#}");
ExitCode::from(2)
}
}
}
async fn run() -> Result<ExitCode> {
let cli = Cli::parse();
let binary = match cli.binary {
Some(path) => path,
None => pager_binary().context("resolve pager binary")?,
};
if !binary.exists() {
bail!("pager binary does not exist: {}", binary.display());
}
let cells: Vec<&'static MatrixCell> = CELLS
.iter()
.filter(|cell| cli.tier == TierArg::Full || cell.tier == Tier::Curated)
.filter(|cell| {
cli.filter
.as_deref()
.is_none_or(|needle| cell.id.contains(needle))
})
.collect();
if cells.is_empty() {
bail!(
"no cells match --tier {:?} --filter {:?}",
cli.tier,
cli.filter
);
}
// The pager child resolves KIGI_SCROLL_LOG against ITS cwd (the
// harness's temp workspace), so a relative artifacts dir — including
// the default — would scatter captures there and starve the finalize
// wait. Absolutize against the invoking cwd.
let artifacts = std::path::absolute(&cli.artifacts)
.with_context(|| format!("absolutize artifacts dir {}", cli.artifacts.display()))?;
let reports = run_cells(&cells, cli.jobs.max(1), &binary, &artifacts).await;
print!("{}", summary_table(&reports));
let report_path = write_report_json(&reports, &artifacts)?;
eprintln!("report: {}", report_path.display());
Ok(ExitCode::from(exit_code(&reports)))
}
/// Run every cell, preserving table order. `jobs == 1` runs inline (the
/// representative-timing default); higher values fan out over a semaphore.
async fn run_cells(
cells: &[&'static MatrixCell],
jobs: usize,
binary: &std::path::Path,
artifacts: &std::path::Path,
) -> Vec<CellReport> {
if jobs == 1 {
let mut reports = Vec::with_capacity(cells.len());
for (i, cell) in cells.iter().enumerate() {
eprintln!("[{}/{}] {}", i + 1, cells.len(), cell.id);
reports.push(run_cell(cell, binary, artifacts).await);
}
return reports;
}
let semaphore = Arc::new(tokio::sync::Semaphore::new(jobs));
let mut set = tokio::task::JoinSet::new();
for (index, &cell) in cells.iter().enumerate() {
let semaphore = semaphore.clone();
let (binary, artifacts) = (binary.to_path_buf(), artifacts.to_path_buf());
set.spawn(async move {
let _permit = semaphore.acquire_owned().await.expect("semaphore open");
eprintln!("running {}", cell.id);
(index, run_cell(cell, &binary, &artifacts).await)
});
}
let mut indexed = Vec::with_capacity(cells.len());
while let Some(joined) = set.join_next().await {
// run_cell converts cell panics into Fail reports itself.
indexed.push(joined.expect("cell task join"));
}
indexed.sort_by_key(|(index, _)| *index);
indexed.into_iter().map(|(_, report)| report).collect()
}
@@ -0,0 +1,291 @@
//! Layer 3: Content controller.
//!
//! An idle pager only renders a splash screen — not useful for scroll,
//! stream, or resize scenarios. [`ContentController`] wraps the shared
//! [`MockInferenceServer`] from `kigi-test-support` and provides the
//! env vars that point the bundled shell agent at it, so the pager ends
//! up rendering real agent output.
//!
//! The caller controls the response text via [`ContentController::set_response`].
//! The mock server streams the set response to every inference request.
use std::path::Path;
use anyhow::{Context, Result};
use kigi_test_support::MockInferenceServer;
pub use kigi_test_support::mock_server::LogEntry;
pub use kigi_test_support::mock_server::MockModelEntry as MockModel;
pub use kigi_test_support::mock_server::StorageUpload;
// SSE event builders for `enqueue_response` scripts (reasoning turns etc.).
pub use kigi_test_support::sse;
pub use kigi_test_support::{ScriptedResponse, SseEvent};
/// Drives content into the pager by serving a mock inference endpoint that
/// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`.
///
/// Thin wrapper over the shared [`MockInferenceServer`]: adds the isolated
/// `$HOME` sandbox and pager env plumbing, and applies the harness defaults
/// the pager depends on (always-200 `/v1/settings`, fixed default response).
///
/// Shuts the server down on drop (the inner server's `Drop`).
pub struct ContentController {
server: MockInferenceServer,
home: tempfile::TempDir,
}
impl ContentController {
/// Start the mock inference server on a random local port.
///
/// Must be called from within a tokio runtime.
pub async fn start() -> Result<Self> {
Self::start_with_models(vec![MockModel::new("test-model")]).await
}
/// Start the mock server with a custom set of models returned by
/// `GET /v1/models`. Use [`MockModel::with_agent_type`] to configure
/// models with different harness types for agent-type-mismatch tests.
pub async fn start_with_models(models: Vec<MockModel>) -> Result<Self> {
let server = MockInferenceServer::start_with_models(models)
.await
.context("start mock inference server")?;
// Pre-delegation parity, both load-bearing for PTY tests: settings
// must be 200 `{"allow_access": true}` (the shared 404-until-set
// default strands the pager on the upsell screen), and the response
// mode must be a fixed text (the shared default is echo).
server.preset_allow_access();
server.set_response(default_response_text());
let home = tempfile::tempdir().context("create temp HOME")?;
Ok(Self { server, home })
}
/// Base URL of the mock server, e.g. `http://127.0.0.1:41823/v1`.
pub fn url(&self) -> String {
self.server.url()
}
/// Isolated `$HOME` directory that the pager should use (keeps its ~/.kigi
/// cache/state out of the real home during tests).
pub fn home(&self) -> &Path {
self.home.path()
}
/// Env vars to pass to the pager process so it hits the mock server
/// with telemetry / feedback disabled.
///
/// Mirrors `kigi_test_support::env::test_env_cmd_tokio`.
pub fn env_for_pager(&self) -> Vec<(String, String)> {
let home = self.home.path().to_string_lossy().into_owned();
let kigi_home = self
.home
.path()
.join(".kigi")
.to_string_lossy()
.into_owned();
vec![
("HOME".into(), home),
// Explicit KIGI_SHARE_DIR prevents leaking the real user's
// config.toml when $HOME alone isn't sufficient (e.g. if
// KIGI_SHARE_DIR is set in the test runner's env).
("KIGI_SHARE_DIR".into(), kigi_home),
("KIGI_CLI_CHAT_PROXY_BASE_URL".into(), self.url()),
("KIGI_XAI_API_BASE_URL".into(), self.url()),
("XAI_API_KEY".into(), "test-key-for-ci".into()),
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
("KIGI_TRACE_UPLOAD".into(), "false".into()),
// Next-prompt autocomplete fires an extra background model call
// at every turn end (default ON). Off by default in PTY tests so
// the mock's fixed response can't leak in as ghost text and
// scripted per-path FIFOs aren't consumed by it. Tests exercising
// the feature re-enable it via extra env.
("KIGI_PROMPT_SUGGESTIONS".into(), "false".into()),
// No inference retries in tests. The mock always answers 200, so a
// retry only ever fires when a turn is deliberately stalled
// (`hold_agent_completions` / a long `chunk_delay`). On a slow
// runner that stall can exceed the client's first-token budget and
// retry the request — and because the mock serves `set_agent_turns`
// by popping one response per REQUEST, a retry consumes the next
// turn's slot, misaligning every following turn (the promoted queue
// prompt then hangs waiting for a response that was already popped).
// Pinning retries to 0 keeps one request == one turn.
("KIGI_MAX_RETRIES".into(), "0".into()),
]
}
/// Replace the mocked assistant response. All subsequent chat completion
/// requests will stream this text word-by-word.
pub fn set_response(&self, text: impl Into<String>) {
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.
pub fn enqueue_response(&self, path: impl Into<String>, response: ScriptedResponse) {
self.server.enqueue_response(path, response);
}
/// Access the underlying mock inference server for advanced scripting.
pub fn server(&self) -> &MockInferenceServer {
&self.server
}
/// Pace the mocked SSE streams: each event is emitted after `delay`.
/// `None` restores instant streaming. Use to hold a turn visibly
/// "streaming" long enough to interact with it (e.g. Esc-cancel tests).
pub fn set_chunk_delay(&self, delay: Option<std::time::Duration>) {
self.server.set_chunk_delay(delay);
}
/// Hold every agent turn's completion until [`release_agent_completions`]
/// is called. Keeps a turn deterministically "streaming" so a test can
/// interact with it (queue edits/removals) without racing turn end.
///
/// [`release_agent_completions`]: Self::release_agent_completions
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();
}
/// Queue one response per agent turn (FIFO) so each carries a distinct
/// sentinel. See [`MockInferenceServer::set_agent_turns`].
pub fn set_turns(&self, turns: impl IntoIterator<Item = String>) {
self.server.set_agent_turns(turns);
}
/// Number of inference requests the pager has made so far.
pub fn request_count(&self) -> u32 {
self.server.request_count()
}
/// Whether the server has seen a chat completion request.
pub fn has_chat_completion(&self) -> bool {
self.server.has_chat_completion_request() || self.server.has_responses_request()
}
/// Snapshot of all received requests — useful for test diagnostics.
pub fn requests(&self) -> Vec<LogEntry> {
self.server.requests()
}
pub fn request_bodies(&self) -> Vec<serde_json::Value> {
self.server.request_bodies()
}
// ── Mock storage controls (park-on-401 e2e) ────────────────────────────
/// Flip the mock `/v1/storage` 401 gate (the auth-outage window).
pub fn set_storage_unauthorized(&self, unauthorized: bool) {
self.server.set_storage_unauthorized(unauthorized);
}
/// Total `/v1/storage` upload attempts, including 401-rejected ones.
pub fn storage_request_count(&self) -> u32 {
self.server.storage_request_count()
}
/// Snapshot of accepted (HTTP 200) `/v1/storage` uploads.
pub fn storage_uploads(&self) -> Vec<StorageUpload> {
self.server.storage_uploads()
}
}
fn default_response_text() -> String {
"Hello from the pty_harness mock inference server.".to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
/// 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.
#[tokio::test]
async fn settings_endpoint_allows_access_by_default() {
let content = ContentController::start().await.unwrap();
let resp = reqwest::get(format!("{}/settings", content.url()))
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body, serde_json::json!({ "allow_access": true }));
}
/// The pre-delegation mock streamed a fixed default text to every
/// request; the shared server defaults to echo.
#[tokio::test]
async fn default_response_streams_fixed_text() {
let content = ContentController::start().await.unwrap();
let body = reqwest::Client::new()
.post(format!("{}/chat/completions", content.url()))
.json(&serde_json::json!({
"model": "test-model",
"messages": [{ "role": "user", "content": "anything" }]
}))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let streamed: String = body
.lines()
.filter_map(|l| l.strip_prefix("data:"))
.map(str::trim_start)
.filter(|d| *d != "[DONE]")
.filter_map(|d| serde_json::from_str::<serde_json::Value>(d).ok())
.filter_map(|v| {
v.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("delta"))
.and_then(|d| d.get("content"))
.and_then(serde_json::Value::as_str)
.map(String::from)
})
.collect();
assert_eq!(streamed, default_response_text());
assert!(content.has_chat_completion());
}
/// `env_for_pager` keeps the exact sandbox + endpoint env contract the
/// pager spawn path depends on.
#[tokio::test]
async fn env_for_pager_shape() {
let content = ContentController::start().await.unwrap();
let env = content.env_for_pager();
let get = |k: &str| {
env.iter()
.find(|(key, _)| key.as_str() == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("HOME").as_deref(), content.home().to_str());
assert_eq!(
get("KIGI_SHARE_DIR").as_deref(),
content.home().join(".kigi").to_str()
);
assert_eq!(get("KIGI_CLI_CHAT_PROXY_BASE_URL"), Some(content.url()));
assert_eq!(get("KIGI_XAI_API_BASE_URL"), Some(content.url()));
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
assert_eq!(get("KIGI_FEEDBACK_ENABLED").as_deref(), Some("false"));
assert_eq!(get("KIGI_TRACE_UPLOAD").as_deref(), Some("false"));
assert_eq!(get("KIGI_PROMPT_SUGGESTIONS").as_deref(), Some("false"));
assert_eq!(get("KIGI_MAX_RETRIES").as_deref(), Some("0"));
assert_eq!(env.len(), 10, "env list must not silently grow or shrink");
}
}
@@ -0,0 +1,94 @@
//! Environment helpers for benchmarking and testing.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
fn workspace_root() -> Result<PathBuf> {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.map(|p| p.to_path_buf())
.context("failed to resolve workspace root from CARGO_MANIFEST_DIR")
}
fn target_dir() -> Result<PathBuf> {
Ok(std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
workspace_root()
.expect("workspace root for target_dir fallback")
.join("target")
}))
}
fn local_pager_binary_path() -> Result<PathBuf> {
Ok(target_dir()?
.join("debug")
.join(format!("kigi-tui{}", std::env::consts::EXE_SUFFIX)))
}
fn ensure_local_pager_binary(binary: &std::path::Path) -> Result<()> {
if binary.exists() {
return Ok(());
}
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
let mut cmd = Command::new(&cargo);
cmd.current_dir(workspace_root()?)
.args(["build", "-p", "kigi-bin", "--bin", "kigi-tui"])
.stdin(Stdio::null())
.envs(kigi_tty_utils::pager_env());
kigi_tty_utils::detach_std_command(&mut cmd);
let output = cmd
.output()
.with_context(|| format!("failed to spawn {cargo} to build kigi-tui"))?;
if !output.status.success() {
bail!(
"failed to build kigi-tui (exit {:?})\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
if !binary.exists() {
bail!(
"kigi-tui build completed but binary missing at {}",
binary.display()
);
}
Ok(())
}
/// Resolve the pager binary path.
///
/// Resolution order:
/// 1. `PAGER_BINARY` env var (for CI / explicit override)
/// 2. `CARGO_BIN_EXE_kigi-tui` (set by `cargo test`)
/// 3. Build locally via `cargo build -p kigi-bin` (the composition-
/// root package that owns the `kigi-tui` binary)
pub fn pager_binary() -> Result<PathBuf> {
if let Ok(path) = std::env::var("PAGER_BINARY") {
let p = PathBuf::from(path);
if !p.exists() {
bail!("PAGER_BINARY does not exist: {}", p.display());
}
// Bazel sets PAGER_BINARY to a runfiles-relative path; portable_pty
// resolves non-absolute paths via PATH lookup instead of the cwd.
return std::path::absolute(&p)
.with_context(|| format!("failed to absolutize PAGER_BINARY: {}", p.display()));
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_kigi-tui") {
let p = PathBuf::from(path);
if p.exists() {
return Ok(p);
}
}
let binary = local_pager_binary_path()?;
ensure_local_pager_binary(&binary)?;
Ok(binary)
}
@@ -0,0 +1,124 @@
//! Cross-suite e2e flow helpers over [`PtyHarness`] / [`ContentController`].
//!
//! The single canonical home for driving/seeding helpers shared by the
//! pager's `pty_e2e` and `leader_pty_e2e` test targets (both depend on this
//! crate); suite-local constants (sizes, sentinels, timeouts) stay in each
//! suite's `common.rs`.
use std::time::{Duration, Instant};
use crate::{ContentController, PtyHarness};
/// Pump PTY output until every label is absent from the visible screen.
pub fn wait_for_labels_absent(h: &mut PtyHarness, labels: &[&str], timeout: Duration) {
let _ = h.wait_until("screen labels to disappear", timeout, |h| {
labels.iter().all(|label| !h.contains_text(label))
});
}
/// Submit `prompt` from `h`, then keep re-pressing Enter until the turn
/// actually starts streaming (`sentinel` appears) or `timeout` elapses.
///
/// In a heavy multi-client leader cluster the driver's submit Enter can be
/// dropped when it races the other client attaching / replaying on the shared
/// leader: the typed prompt is left sitting unsubmitted in the composer, the
/// turn never starts, and a plain `wait_for_text` then times out (the observed
/// `leader_two_clients_shared_session` flake — A idle with `again` still in the
/// composer at 75s). Re-pressing Enter is safe and idempotent: submitting takes
/// the composer draft synchronously (`std::mem::take` in `dispatch`), so once a
/// turn has really been sent the composer is empty and an extra Enter is a
/// no-op. It can only submit a still-stuck prompt, never double-submit a sent
/// one (which would break exactly-once scrollback asserts).
pub fn submit_turn(h: &mut PtyHarness, prompt: &str, sentinel: &str, timeout: Duration) {
h.inject_keys(format!("{prompt}\r").as_bytes())
.expect("inject prompt submit");
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
// Per-attempt sub-budget, generous enough that a genuinely in-flight
// submit resolves before we re-nudge (so the re-nudge only ever fires
// on an empty composer, where it is a no-op).
if h.wait_for_text(sentinel, Duration::from_secs(10).min(remaining))
.is_ok()
{
return;
}
assert!(
Instant::now() < deadline,
"timed out after {timeout:?} waiting for {sentinel:?}\nscreen:\n{}",
h.screen_contents()
);
let _ = h.inject_keys(b"\r");
}
}
/// Count only inference requests (chat completions / responses / messages),
/// ignoring incidental GETs like /v1/models and /v1/settings, so a replay
/// invariant means "no turn was re-driven" rather than "no HTTP at all".
pub fn inference_request_count(content: &ContentController) -> usize {
content
.requests()
.iter()
.filter(|e| {
e.path.contains("/chat/completions")
|| e.path.contains("/responses")
|| e.path.contains("/messages")
})
.count()
}
/// Seed a fake xAI OAuth entry into the isolated home's `auth.json` so the
/// shell has session auth (the harness's `XAI_API_KEY` is ApiKey/BYOK mode
/// and never enters the auth manager). Load-bearing details: the scope key
/// must be `<issuer>::<client_id>`, `auth_mode` must be `oidc`, and
/// `expires_at` must be far-future so no network refresh is attempted; the
/// mock server accepts any bearer. Pair with [`oauth_env_for_pager`].
pub fn seed_fake_oauth(content: &ContentController, user: &str) {
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).expect("create temp .kigi");
std::fs::write(
kigi_home.join("auth.json"),
format!(
r#"{{
"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
"key": "pty-test-oauth-token",
"auth_mode": "oidc",
"create_time": "2026-01-01T00:00:00Z",
"user_id": "{user}",
"email": "{user}@test.invalid",
"expires_at": "2030-01-01T00:00:00Z",
"refresh_token": "pty-test-refresh-token",
"oidc_issuer": "https://auth.x.ai",
"oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828"
}}
}}"#
),
)
.expect("seed fake oauth auth.json");
}
/// [`ContentController::env_for_pager`] minus `XAI_API_KEY`, so the entry
/// written by [`seed_fake_oauth`] is the active credential.
pub fn oauth_env_for_pager(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.retain(|(k, _)| k != "XAI_API_KEY");
env
}
/// Drive `/new` until `model` shows on screen. Campaigns apply to **new
/// sessions only** and the pager's settings prefetch is deliberately 2s-capped,
/// so on a loaded runner the first session can legitimately open pre-campaign;
/// each `/new` after the settings fetch lands re-resolves with the campaign.
pub fn wait_for_model_via_new_sessions(h: &mut PtyHarness, model: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if h.contains_text(model) {
return true;
}
if Instant::now() >= deadline {
return false;
}
let _ = h.inject_keys(b"/new\r");
h.update(Duration::from_millis(3000));
}
}
@@ -0,0 +1,198 @@
//! REAL host-clipboard plumbing shared by the OS-native paste e2e tests and
//! the `paste_latency` bench: `pbcopy` / `pbpaste` / `osascript` on macOS,
//! PowerShell `Set-Clipboard` / `Get-Clipboard` / WinForms `SetImage` on
//! Windows.
//!
//! Everything here mutates or reads the MACHINE-GLOBAL clipboard, so callers
//! must serialize against each other (e.g. `#[serial_test::serial]`) and
//! should hold a [`HostClipboardTextGuard`] to restore the prior text. CI
//! sessions without a usable clipboard are detected via
//! [`clipboard_roundtrip_works`] so tests can skip instead of fail.
//!
//! Compiles on every platform so cross-platform builds of the consumers stay
//! green (the bench gates at runtime); on unsupported hosts the tool spawns
//! simply fail.
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
/// Copy `text` to the host clipboard via `pbcopy`.
#[cfg(not(target_os = "windows"))]
pub fn pbcopy(text: &str) -> Result<()> {
let mut cmd = Command::new("pbcopy");
cmd.stdin(Stdio::piped());
kigi_tty_utils::detach_std_command(&mut cmd);
let mut child = cmd.spawn().context("spawn pbcopy")?;
child
.stdin
.take()
.context("pbcopy stdin")?
.write_all(text.as_bytes())
.context("write pbcopy stdin")?;
let status = child.wait().context("wait pbcopy")?;
if !status.success() {
bail!("pbcopy exited with {status}");
}
Ok(())
}
/// Copy `text` to the host clipboard via PowerShell `Set-Clipboard`.
#[cfg(target_os = "windows")]
pub fn pbcopy(text: &str) -> Result<()> {
// Text travels over stdin, never inside the command line — no quoting.
let mut cmd = Command::new("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Set-Clipboard -Value ([Console]::In.ReadToEnd())",
])
.stdin(Stdio::piped());
kigi_tty_utils::detach_std_command(&mut cmd);
let mut child = cmd.spawn().context("spawn powershell Set-Clipboard")?;
child
.stdin
.take()
.context("powershell stdin")?
.write_all(text.as_bytes())
.context("write powershell stdin")?;
let status = child.wait().context("wait powershell Set-Clipboard")?;
if !status.success() {
bail!("powershell Set-Clipboard exited with {status}");
}
Ok(())
}
/// Current host clipboard TEXT via `pbpaste` (`None` when unavailable).
#[cfg(not(target_os = "windows"))]
pub fn pbpaste() -> Option<String> {
let mut cmd = Command::new("pbpaste");
kigi_tty_utils::detach_std_command(&mut cmd);
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Current host clipboard TEXT via PowerShell `Get-Clipboard` (`None` when
/// unavailable).
#[cfg(target_os = "windows")]
pub fn pbpaste() -> Option<String> {
let mut cmd = Command::new("powershell");
// Console::Out.Write avoids the trailing newline PowerShell's pipeline
// output would append (roundtrip checks compare exact text).
cmd.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$c = Get-Clipboard -Raw; if ($null -ne $c) { [Console]::Out.Write($c) }",
]);
kigi_tty_utils::detach_std_command(&mut cmd);
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Put the PNG at `path` on the host clipboard as a raster (`«class PNGf»`).
#[cfg(not(target_os = "windows"))]
pub fn set_clipboard_png(path: &Path) -> Result<()> {
let script = format!(
"set the clipboard to (read (POSIX file \"{}\") as «class PNGf»)",
path.display()
);
let mut cmd = Command::new("osascript");
cmd.arg("-e").arg(&script);
kigi_tty_utils::detach_std_command(&mut cmd);
let status = cmd.status().context("spawn osascript")?;
if !status.success() {
bail!("osascript set-clipboard-PNG exited with {status}");
}
Ok(())
}
/// Put the PNG at `path` on the host clipboard as a raster via a WinForms
/// `Clipboard::SetImage` PowerShell one-liner.
#[cfg(target_os = "windows")]
pub fn set_clipboard_png(path: &Path) -> Result<()> {
use base64::Engine as _;
// WinForms Clipboard requires an STA thread; -EncodedCommand (base64 of
// UTF-16LE) sidesteps every cmd/PowerShell quoting layer, leaving only
// the PS single-quote escape for the embedded path.
let ps_path = path.display().to_string().replace('\'', "''");
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; \
$img = [System.Drawing.Image]::FromFile('{ps_path}'); \
[System.Windows.Forms.Clipboard]::SetImage($img); \
$img.Dispose()"
);
let utf16: Vec<u8> = script
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
let mut cmd = Command::new("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-STA",
"-EncodedCommand",
&encoded,
]);
kigi_tty_utils::detach_std_command(&mut cmd);
let status = cmd.status().context("spawn powershell SetImage")?;
if !status.success() {
bail!("powershell SetImage exited with {status}");
}
Ok(())
}
/// Write a small solid-color PNG under `dir` and return its path.
pub fn write_fixture_png(dir: &Path) -> Result<PathBuf> {
let path = dir.join("host_clipboard_fixture.png");
let buf: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
image::ImageBuffer::from_pixel(64, 64, image::Rgba([200, 40, 120, 255]));
buf.save(&path)
.context("write host clipboard fixture png")?;
Ok(path)
}
/// Whether the host clipboard actually works in this session: sets a nonce
/// via the text helper and reads it back. False on clipboard-less CI sessions
/// (e.g. a Windows service session with no interactive desktop) so tests can
/// SKIP loudly instead of failing on environment.
pub fn clipboard_roundtrip_works() -> bool {
let nonce = format!("HOSTCLIPROUNDTRIP{}", std::process::id());
if pbcopy(&nonce).is_err() {
return false;
}
// trim_end: some clipboard tool chains append a trailing newline.
pbpaste().is_some_and(|t| t.trim_end() == nonce)
}
/// Best-effort save/restore of the host TEXT clipboard around a test or bench
/// run. Restores on drop (panic/unwind included). A prior IMAGE clipboard
/// cannot be restored — `pbpaste` only reads the text representation.
pub struct HostClipboardTextGuard {
prior: Option<String>,
}
impl HostClipboardTextGuard {
pub fn save() -> Self {
Self { prior: pbpaste() }
}
}
impl Drop for HostClipboardTextGuard {
fn drop(&mut self) {
if let Some(prior) = self.prior.take() {
// Non-panicking restore: a panicking test must still unwind cleanly.
let _ = pbcopy(&prior);
}
}
}
@@ -0,0 +1,237 @@
//! Multi-client leader cluster: one shared leader, N pager clients, plus
//! inspection of the durable session log so reattach tests can assert on
//! what actually persisted.
//!
//! Every other leader test is single-client/single-leader; [`LeaderCluster`]
//! is the missing abstraction for "one leader, several pager clients sharing
//! its session". One [`ContentController`] gives one shared `$HOME` (hence one
//! elected leader) plus a fixed leader socket beneath its `KIGI_SHARE_DIR`; clients
//! spawn with the `--leader`/`--leader-socket` flags so they all attach to the
//! SAME leader. It also exposes the leader's durable `updates.jsonl` log so a
//! reattach test can assert on the persisted, replayable turn-completion
//! records — the genuine end-to-end signal behind durable turn completion.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use serde_json::Value;
use crate::{ContentController, PtyHarness, pager_binary};
/// One shared mock-backed leader plus the pager clients attached to it.
pub struct LeaderCluster {
content: ContentController,
binary: PathBuf,
socket: PathBuf,
rows: u16,
cols: u16,
}
impl LeaderCluster {
/// Start the cluster: one [`ContentController`] (one shared `$HOME` =>
/// one leader) and a fixed leader socket under its `KIGI_SHARE_DIR`.
pub async fn start(rows: u16, cols: u16) -> Result<Self> {
let content = ContentController::start()
.await
.context("start content controller")?;
// One shared KIGI_SHARE_DIR => one leader; the socket lives beneath it so
// every client (sharing the same env) elects/attaches to the same one.
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).context("create grok home")?;
let socket = kigi_home.join("leader-e2e.sock");
let binary = pager_binary().context("resolve pager binary")?;
Ok(Self {
content,
binary,
socket,
rows,
cols,
})
}
/// Spawn the leader-electing client (`--leader --leader-socket <S>` plus
/// `extra_args`); it starts a fresh session and brings up the leader.
pub fn spawn_leader(&self, extra_args: &[&str]) -> Result<PtyHarness> {
self.spawn_client(&[], extra_args)
}
/// Attach another client that resumes the shared session through the SAME
/// leader (`--leader --leader-socket <S> --resume` plus `extra_args`).
pub fn attach(&self, extra_args: &[&str]) -> Result<PtyHarness> {
self.spawn_client(&["--resume"], extra_args)
}
/// Spawn a client wired to the shared leader socket. `mode_args` carries
/// the per-role flag (`--resume` for attachers); `extra_args` is the
/// caller's.
fn spawn_client(&self, mode_args: &[&str], extra_args: &[&str]) -> Result<PtyHarness> {
let socket = self.socket.to_str().context("socket path is utf-8")?;
let mut args: Vec<&str> = vec!["--leader", "--leader-socket", socket];
args.extend_from_slice(mode_args);
args.extend_from_slice(extra_args);
PtyHarness::spawn_with_content(&self.binary, self.rows, self.cols, &self.content, &args)
.context("spawn pager client on shared leader")
}
/// The shared content controller (mock inference server + sandbox env).
pub fn content(&self) -> &ContentController {
&self.content
}
/// The cluster's sessions root: `KIGI_SHARE_DIR/sessions` (layout below is
/// `sessions/<encoded-cwd>/<session-id>/updates.jsonl`).
fn sessions_dir(&self) -> PathBuf {
self.content.home().join(".kigi").join("sessions")
}
/// The session-update payload of every record across every `updates.jsonl`
/// under the cluster's [`sessions_dir`](Self::sessions_dir) — i.e. the
/// `params.update` object of each persisted envelope line, so a caller can
/// match on its `sessionUpdate` tag directly. Scans ALL sessions under the
/// cluster (fine for the single-session clusters these tests build).
///
/// Infallible by design: a file that vanishes mid-walk, or whose appended
/// tail tore across a multi-byte UTF-8 boundary (so `read_to_string`
/// fails), is skipped for this call and picked up on the next one.
pub fn session_updates(&self) -> Vec<Value> {
let mut files = Vec::new();
collect_updates_files(&self.sessions_dir(), &mut files);
let mut out = Vec::new();
for file in files {
// Skip a vanished file or a torn multi-byte tail; the next poll retries.
if let Ok(text) = std::fs::read_to_string(&file) {
out.extend(parse_update_payloads(&text));
}
}
out
}
/// Poll [`session_updates`](Self::session_updates) until a record with
/// `sessionUpdate == "turn_completed"` appears, returning that (inner)
/// update payload, or error on timeout. Scans ALL sessions under the
/// cluster (fine for the single-session clusters these tests build).
pub fn wait_for_turn_completed(&self, timeout: Duration) -> Result<Value> {
let deadline = Instant::now() + timeout;
loop {
let updates = self.session_updates();
if let Some(rec) = updates.iter().find(|u| is_turn_completed(u)) {
return Ok(rec.clone());
}
if Instant::now() >= deadline {
// Surface what WAS persisted so "zero records / env problem" is
// distinguishable from "records present but no turn_completed /
// producer regression".
let tags: std::collections::BTreeSet<&str> = updates
.iter()
.filter_map(|u| u.get("sessionUpdate").and_then(Value::as_str))
.collect();
anyhow::bail!(
"timed out after {timeout:?} waiting for a turn_completed record under {}; \
saw {} update record(s) with sessionUpdate tags {tags:?}",
self.sessions_dir().display(),
updates.len(),
);
}
// Sync FS poll mirrors the harness's blocking wait_for_text; a stat
// every 150ms is cheap and fine on a multi_thread runtime worker.
std::thread::sleep(Duration::from_millis(150));
}
}
}
/// Whether a session-update payload is a `turn_completed` terminal.
fn is_turn_completed(update: &Value) -> bool {
update.get("sessionUpdate").and_then(Value::as_str) == Some("turn_completed")
}
/// Parse the `params.update` payload out of each non-blank line of an
/// `updates.jsonl` body, assuming the enveloped on-disk shape current sessions
/// always write (`{..,"params":{"update":{..}}}`). A line that is blank, fails
/// to parse (a torn trailing line that is still valid UTF-8), or carries no
/// `params.update` is skipped — never failing the batch. (A torn *multi-byte*
/// tail instead fails the file read upstream, skipping the whole file for that
/// poll; see [`LeaderCluster::session_updates`].)
fn parse_update_payloads(text: &str) -> Vec<Value> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let envelope: Value = serde_json::from_str(line).ok()?;
envelope.get("params")?.get("update").cloned()
})
.collect()
}
/// Recursively collect every `updates.jsonl` beneath `dir` (a manual walk to
/// avoid a new crate dep). A missing/unreadable dir yields nothing — sessions
/// may not exist yet, and the walk is re-run on every poll.
fn collect_updates_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
// No-follow file type: a symlinked directory has `is_dir() == false`,
// so a symlink cycle can never recurse forever here.
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
collect_updates_files(&path, out);
} else if path.file_name().and_then(|n| n.to_str()) == Some("updates.jsonl") {
out.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Wrap a session update as the envelope stored in `updates.jsonl`.
fn envelope(update_json: &str) -> String {
format!(
r#"{{"timestamp":1,"method":"_x.ai/session/update","params":{{"sessionId":"s","update":{update_json}}}}}"#
)
}
#[test]
fn parse_update_payloads_unwraps_and_tolerates_torn_trailing_line() {
let body = format!(
"{}\n{}\n{{\"timestamp\":2,\"method\":\"_x.ai/sess",
envelope(
r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}"#
),
envelope(
r#"{"sessionUpdate":"turn_completed","prompt_id":"p-1","stop_reason":"end_turn"}"#
),
);
let updates = parse_update_payloads(&body);
// The two complete lines parse; the torn final line is dropped.
assert_eq!(updates.len(), 2);
let completed = updates
.iter()
.find(|u| is_turn_completed(u))
.expect("turn_completed payload is unwrapped from params.update");
assert_eq!(completed["stop_reason"], "end_turn");
assert_eq!(completed["prompt_id"], "p-1");
}
#[test]
fn parse_update_payloads_skips_blank_and_payloadless_lines() {
let body = format!(
"\n \n{}\n{{\"timestamp\":3,\"method\":\"x\",\"params\":{{\"sessionId\":\"s\"}}}}\n",
envelope(
r#"{"sessionUpdate":"turn_completed","prompt_id":"p","stop_reason":"cancelled"}"#
),
);
let updates = parse_update_payloads(&body);
// Only the one envelope carrying params.update survives.
assert_eq!(updates.len(), 1);
assert!(is_turn_completed(&updates[0]));
assert_eq!(updates[0]["stop_reason"], "cancelled");
}
}
@@ -0,0 +1,635 @@
//! Unified PTY harness for kigi-tui.
//!
//! The same layered API serves three consumers:
//!
//! 1. **Regression scenarios** (e.g. `scenarios::plan_approval_resume`,
//! exercised via `tests/` in this crate and `pty-scenario` YAML under
//! `kigi-tui/tests/scenarios/`) — assert screen contents and
//! multi-process resume behavior.
//! 2. **Benchmarks** (`benches/pty_bench.rs`) — run timing scenarios, collect
//! per-frame timings, emit JSON / compare against baselines.
//! 3. **Ad-hoc scenario runs** — spin up the harness to reproduce issues locally.
//!
//! ## Layers
//!
//! - **`pty`** (L1) — PTY management (spawn, inject keys, resize, drain).
//! - **`screen`** (L2a) — Virtual terminal state via `alacritty_terminal` ("what the user sees").
//! - **`timing`** (L2b) — Per-frame durations via `?2026 h/l` markers.
//! - **`content`** (L3) — Mock inference server driving real content into the pager.
//! - **`scenarios`** — Named, parameterised workloads returning `BenchResults`.
//! - **`results`** — Aggregated statistics, baseline compare.
//! - **`scroll_matrix`** — `KIGI_SCROLL_LOG` JSONL ingestion for the scroll validation matrix.
//! - **`env`** — Binary resolution and workspace path helpers.
//! - **`flows`** — Cross-suite drive/seed helpers shared by the pager's e2e targets.
pub mod content;
pub mod env;
pub mod flows;
pub mod host_clipboard;
pub mod leader;
pub mod pty;
pub mod results;
pub mod scenarios;
pub mod screen;
pub mod scripted;
pub mod scroll_matrix;
pub mod timing;
pub use content::{ContentController, MockModel, ScriptedResponse, SseEvent, sse};
pub use env::pager_binary;
pub use flows::{
inference_request_count, oauth_env_for_pager, seed_fake_oauth, submit_turn,
wait_for_labels_absent, wait_for_model_via_new_sessions,
};
pub use host_clipboard::HostClipboardTextGuard;
pub use leader::LeaderCluster;
use pty::PtyRead;
pub use pty::{PtyController, keys};
pub use results::{BenchResults, compare_baseline};
pub use scenarios::Scenario;
pub use screen::ScreenTracker;
pub use scripted::{
BugFinding, BugSeverity, DimensionAssertion, EnvVar, EnvironmentConfig, ImageFixture,
ImageFixtureKind, MockConfig, MouseButton, MousePoint, SGR_SCROLL_DOWN, SGR_SCROLL_UP,
ScenarioStep, ScriptedRunConfig, ScriptedRunReport, ScriptedRunStatus, ScriptedScenario,
ScriptedScenarioRunner, ScrollDirection, StepOutcome, StepStatus, TerminalConfig,
VisualArtifact,
};
pub use timing::{FrameTiming, FrameTimingParser};
// Re-export ptyctl types for richer terminal emulation, vim key notation,
// and styled output support.
pub use ptyctl::keys::parse_keys;
pub use ptyctl::styled::{StyledLine, StyledRun};
pub use ptyctl::term::{ScreenOutput, Terminal as AlacrittyTerminal};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use portable_pty::PtySize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PtyPump {
Chunk,
Timeout,
Closed,
}
/// High-level harness that composes PTY control, screen state, and frame timing.
///
/// The key method is [`update`](PtyHarness::update), which receives PTY output
/// chunks inline and feeds each to **both** the [`ScreenTracker`] and
/// [`FrameTimingParser`] as it arrives. This preserves inter-chunk timing for
/// accurate frame measurement.
pub struct PtyHarness {
pty: PtyController,
screen: ScreenTracker,
timing: FrameTimingParser,
raw_output: Vec<u8>,
/// Spawn instant — the time origin for asciinema cast event timestamps.
spawned_at: Instant,
/// Per-chunk cast events as `(elapsed_secs, end_offset_into_raw_output)`.
/// Each event's bytes are `raw_output[prev_end..end]`, so the chunks are
/// not duplicated in memory.
cast_events: Vec<(f64, usize)>,
/// Terminal size at spawn as `(cols, rows)` for the cast header.
cast_size: (u16, u16),
/// When true, [`update`](Self::update) forwards terminal-generated replies
/// (cursor-position reports, device attributes, …) back to the child.
/// Off by default so tests that script their own probe replies (e.g.
/// `pty_xtversion`) keep full control; minimal-mode tests turn it on so the
/// inline viewport's startup cursor query completes instead of timing out.
respond_to_queries: bool,
}
impl PtyHarness {
/// Spawn the pager in a PTY and create a new harness.
///
/// Both `rows` and `cols` follow terminal convention: `(rows, cols)`.
pub fn new(
binary: &Path,
rows: u16,
cols: u16,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Self> {
Self::new_in_dir(binary, rows, cols, args, env, None)
}
/// Like [`new`](Self::new), with an explicit working directory (`None` inherits).
pub fn new_in_dir(
binary: &Path,
rows: u16,
cols: u16,
args: &[&str],
env: &[(&str, &str)],
cwd: Option<&Path>,
) -> Result<Self> {
let size = PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
};
let pty = PtyController::spawn_in_dir(binary, size, args, env, cwd)
.context("failed to spawn pager in PTY")?;
Ok(Self {
pty,
screen: ScreenTracker::new(rows, cols),
timing: FrameTimingParser::new(),
raw_output: Vec::new(),
spawned_at: Instant::now(),
cast_events: Vec::new(),
cast_size: (cols, rows),
respond_to_queries: false,
})
}
/// Enable (or disable) forwarding terminal-generated replies back to the
/// child during [`update`](Self::update). Real terminals answer device
/// queries automatically; the harness leaves this off by default so probe
/// tests can script their own replies. Minimal-mode tests enable it so the
/// inline viewport's startup cursor-position query (`ESC[6n`) is answered
/// and `--minimal` is not silently downgraded to full-screen inline.
pub fn set_respond_to_queries(&mut self, enabled: bool) {
self.respond_to_queries = enabled;
}
/// Spawn the pager with env vars from a [`ContentController`] attached.
///
/// This is the common pattern for both e2e tests and benchmarks:
///
/// ```no_run
/// # use std::time::Duration;
/// # use kigi_pager_pty_harness::{PtyHarness, ContentController, pager_binary};
/// # async fn example() -> anyhow::Result<()> {
/// let content = ContentController::start().await?;
/// content.set_response("# Hello\n\nAgent said hi.");
///
/// let mut harness = PtyHarness::spawn_with_content(
/// &pager_binary()?, 50, 120, &content, &[],
/// )?;
/// harness.wait_for_text("Hello", Duration::from_secs(10))?;
/// harness.quit()?;
/// # Ok(()) }
/// ```
pub fn spawn_with_content(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
) -> Result<Self> {
Self::spawn_with_content_in_dir(binary, rows, cols, content, extra_args, None)
}
/// Like [`spawn_with_content`](Self::spawn_with_content), with an explicit working directory.
pub fn spawn_with_content_in_dir(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
cwd: Option<&Path>,
) -> Result<Self> {
let env = content.env_for_pager();
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
Self::new_in_dir(binary, rows, cols, extra_args, &env_refs, cwd)
}
// ── PTY control ──────────────────────────────────────────────────
/// Inject raw key bytes into the PTY.
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
self.pty.inject_keys(keys)
}
/// Resize the PTY and virtual screen. Arguments are `(rows, cols)`.
pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
self.pty.resize(rows, cols)?;
self.screen.resize(rows, cols);
Ok(())
}
// ── Update: receive PTY output inline → feed both parsers ────────
/// Receive PTY output for up to `timeout`, feeding each chunk to both
/// the screen state tracker and the frame timing parser as it arrives.
///
/// Processing inline (rather than buffering all chunks first) preserves
/// inter-chunk timing so that `FrameTimingParser` records accurate
/// wall-clock frame durations.
pub fn update(&mut self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() || !matches!(self.pump_one(remaining), PtyPump::Chunk) {
break;
}
}
}
fn pump_one(&mut self, timeout: Duration) -> PtyPump {
match self.pty.recv_chunk(timeout) {
PtyRead::Chunk(chunk) => {
self.raw_output.extend_from_slice(&chunk);
self.cast_events.push((
self.spawned_at.elapsed().as_secs_f64(),
self.raw_output.len(),
));
self.screen.feed(&chunk);
self.timing.feed(&chunk);
if self.respond_to_queries {
let responses = self.screen.drain_responses();
if !responses.is_empty() {
let _ = self.pty.inject_keys(&responses);
}
}
PtyPump::Chunk
}
PtyRead::Timeout => PtyPump::Timeout,
PtyRead::Closed => PtyPump::Closed,
}
}
/// Feed bytes **directly into the virtual screen only**, bypassing the
/// child (grok).
///
/// Simulates an out-of-band repaint/reflow by an outer layer (tmux, or an
/// nvim/vim `:terminal`) that changes what's on screen without going
/// through grok's stdout. Used to reproduce the doubled-line class of bugs
/// where grok's diff renderer never re-asserts a region it didn't write
/// itself (since the harness is a single faithful emulator and cannot nest
/// a real tmux/nvim).
pub fn feed_screen(&mut self, bytes: &[u8]) {
self.screen.feed(bytes);
}
/// Check whether the child process is still running.
pub fn is_running(&mut self) -> bool {
self.pty.is_running()
}
// ── Screen state queries ─────────────────────────────────────────
/// Return structured plain-text screen contents.
pub fn screen_output(&self) -> ScreenOutput {
self.screen.output()
}
/// Return the full text contents of the virtual screen.
pub fn screen_contents(&self) -> String {
self.screen.contents()
}
/// Return the full screen with style information.
pub fn screen_styled(&self) -> Vec<StyledLine> {
self.screen.styled()
}
/// Render the current screen as HTML.
pub fn screen_html(&self) -> String {
self.screen.html()
}
/// Check whether the screen contains the given text.
pub fn contains_text(&self, text: &str) -> bool {
self.screen.contains(text)
}
/// Pump PTY output until `condition` becomes true or `timeout` expires.
///
/// The condition is checked before the first pump and after each output
/// slice. `description` names the semantic state in timeout diagnostics.
pub fn wait_until(
&mut self,
description: &str,
timeout: Duration,
mut condition: impl FnMut(&Self) -> bool,
) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
if condition(self) {
return Ok(());
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"timed out after {timeout:?} waiting for {description}\n\
process running: {}\nscreen contents:\n{}",
self.pty.is_running(),
self.screen.contents()
);
}
match self.pump_one(Duration::from_millis(50).min(remaining)) {
PtyPump::Chunk | PtyPump::Timeout => {}
PtyPump::Closed => {
anyhow::bail!(
"PTY closed while waiting for {description}\n\
process running: false\nscreen contents:\n{}\nraw output:\n{}",
self.screen.contents(),
String::from_utf8_lossy(&self.raw_output)
);
}
}
}
}
/// Like [`Self::wait_until`], but the condition must remain true for `hold`.
///
/// The single `timeout` covers both reaching the condition and holding it;
/// PTY output continues to be pumped throughout the stability window.
pub fn wait_until_stable(
&mut self,
description: &str,
timeout: Duration,
hold: Duration,
mut condition: impl FnMut(&Self) -> bool,
) -> Result<()> {
let deadline = Instant::now() + timeout;
let mut true_since = None;
loop {
if condition(self) {
let since = true_since.get_or_insert_with(Instant::now);
if since.elapsed() >= hold {
return Ok(());
}
} else {
true_since = None;
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"timed out after {timeout:?} waiting for {description} to remain true for \
{hold:?}\nprocess running: {}\nscreen contents:\n{}",
self.pty.is_running(),
self.screen.contents()
);
}
match self.pump_one(Duration::from_millis(50).min(remaining)) {
PtyPump::Chunk | PtyPump::Timeout => {}
PtyPump::Closed => {
anyhow::bail!(
"PTY closed while waiting for {description} to remain true for {hold:?}\n\
process running: false\nscreen contents:\n{}\nraw output:\n{}",
self.screen.contents(),
String::from_utf8_lossy(&self.raw_output)
);
}
}
}
}
/// Block until the screen contains `text` or `timeout` expires.
pub fn wait_for_text(&mut self, text: &str, timeout: Duration) -> Result<()> {
self.wait_until(&format!("screen text {text:?}"), timeout, |h| {
h.contains_text(text)
})
}
/// Block until the visible screen no longer contains `text`.
pub fn wait_for_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> {
self.wait_until(
&format!("screen text {text:?} to disappear"),
timeout,
|h| !h.contains_text(text),
)
}
/// Wait for a rendered response to reach the idle prompt state.
///
/// Call this after observing turn output: the running status and cancel
/// keybar disappear only after the pager finalizes the turn.
pub fn wait_for_turn_idle(&mut self, timeout: Duration) -> Result<()> {
self.wait_until_stable(
"turn to become idle",
timeout,
Duration::from_millis(250),
|h| {
!h.contains_text("Ctrl+c:cancel")
&& !h.contains_text("Waiting for response")
&& !h.contains_text("Responding")
},
)
}
/// Return all raw bytes emitted by the child PTY so far.
pub fn raw_output(&self) -> &[u8] {
&self.raw_output
}
/// Write everything the child PTY emitted so far as an asciinema v2 cast
/// (`.cast`), one output event per received chunk with its original
/// arrival timestamp. Replayable locally with `asciinema play`. Bytes are
/// decoded lossily so binary escapes cannot poison the JSON encoding.
///
/// Limitation: the header is pinned to the spawn-time size and no `"r"`
/// resize events are emitted, so a cast from a test that calls
/// [`resize`](Self::resize) plays back at the original geometry.
pub fn write_cast(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create cast dir {}", parent.display()))?;
}
let (cols, rows) = self.cast_size;
let mut out = String::new();
out.push_str(&serde_json::json!({"version": 2, "width": cols, "height": rows}).to_string());
out.push('\n');
let mut start = 0usize;
for (elapsed, end) in &self.cast_events {
let mut end = *end;
// A multi-byte codepoint split across two PTY reads must not be
// lossy-decoded in halves: back off to the char boundary and let
// the partial bytes ride in the next event (a dangling tail at
// end-of-capture still decodes lossily — nothing to carry into).
while end > start
&& end < self.raw_output.len()
&& (self.raw_output[end] & 0xC0) == 0x80
{
end -= 1;
}
if end == start {
continue;
}
let data = String::from_utf8_lossy(&self.raw_output[start..end]);
out.push_str(&serde_json::json!([elapsed, "o", data]).to_string());
out.push('\n');
start = end;
}
std::fs::write(path, out).with_context(|| format!("write cast {}", path.display()))
}
// ── Scrollback queries (minimal mode commits blocks into native history) ──
/// The terminal's scrollback history as text (oldest line first).
pub fn scrollback_text(&self) -> String {
self.screen.scrollback_text()
}
/// Scrollback history + the visible screen, joined oldest→newest. Use for
/// minimal-mode assertions: a committed block may be on-screen or scrolled
/// above the pinned viewport depending on how much has accumulated.
pub fn full_text(&self) -> String {
self.screen.full_text()
}
/// Whether scrollback + visible screen contains `text`.
pub fn contains_full_text(&self, text: &str) -> bool {
self.screen.full_contains(text)
}
/// Block until scrollback + visible screen contains `text`, or `timeout`
/// expires. The scrollback-aware companion to [`Self::wait_for_text`] for
/// content that may have scrolled above the viewport (minimal mode).
pub fn wait_for_full_text(&mut self, text: &str, timeout: Duration) -> Result<()> {
let result = self.wait_until(&format!("full text {text:?}"), timeout, |h| {
h.contains_full_text(text)
});
result.map_err(|error| {
anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text())
})
}
/// Block until scrollback + visible screen no longer contains `text`.
pub fn wait_for_full_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> {
let result = self.wait_until(&format!("full text {text:?} to disappear"), timeout, |h| {
!h.contains_full_text(text)
});
result.map_err(|error| {
anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text())
})
}
/// Count Kitty graphics APC sequences that carry image data or placement in
/// the raw PTY output so far (delete / capability-query escapes excluded).
///
/// These escapes are written into the synchronized-update frame buffer
/// (outside the vt100 cell grid), so they aren't visible to `wait_for_text`;
/// scanning the raw bytes is the only way to observe them.
pub fn count_kitty_graphics(&self) -> usize {
scripted::count_kitty_graphics(&self.raw_output)
}
/// Block until at least `min` Kitty graphics APC sequences (`ESC _ G`) have
/// appeared in the raw PTY output, or `timeout` expires.
///
/// Polling the raw bytes avoids a fixed sleep (flake under load) and returns
/// as soon as the image is transmitted/placed.
pub fn wait_for_kitty_graphics(&mut self, min: usize, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
if self.count_kitty_graphics() >= min {
return Ok(());
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"timed out after {timeout:?} waiting for {min} Kitty graphics escape(s); \
found {}",
self.count_kitty_graphics()
);
}
self.update(Duration::from_millis(50).min(remaining));
}
}
/// Return the current cursor position as `(row, col)`.
pub fn cursor_position(&self) -> (u16, u16) {
self.screen.cursor_position()
}
// ── Frame timing queries ─────────────────────────────────────────
/// Return all recorded frame timings.
pub fn frame_timings(&self) -> &[FrameTiming] {
self.timing.timings()
}
/// Compute aggregated benchmark results from collected frame timings.
pub fn bench_results(&self, scenario: &str, wall_time: Duration) -> BenchResults {
BenchResults::from_timings(scenario, self.timing.timings(), wall_time)
}
/// Return the total number of completed frames.
pub fn frame_count(&self) -> u64 {
self.timing.frame_count()
}
/// Reset all frame timing data.
pub fn reset_timing(&mut self) {
self.timing.reset();
}
// ── Lifecycle ────────────────────────────────────────────────────
/// Send 'q' and wait for the child process to exit (5s timeout, then kill).
pub fn quit(&mut self) -> Result<()> {
self.pty.quit()
}
/// Wait up to `timeout` for the child to exit, returning its exit code
/// (`None` if it's still running at the deadline). Call once and cache the
/// result — the underlying `try_wait` reaps the child.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
self.pty.wait_exit_code(timeout)
}
/// Wait for child exit, then drain final PTY output through EOF or quiet.
///
/// `exit_timeout` applies only until exit. Once exit is observed, the known
/// status is preserved while a separate bounded drain phase runs.
pub fn wait_for_exit_and_drain(
&mut self,
exit_timeout: Duration,
drain_timeout: Duration,
) -> Result<u32> {
let exit_deadline = Instant::now() + exit_timeout;
let exit_code = loop {
if let Some(code) = self.pty.try_exit_code()? {
break code;
}
let remaining = exit_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"timed out after {exit_timeout:?} waiting for child exit\n\
process running: true\nscreen contents:\n{}\nraw output:\n{}",
self.screen.contents(),
String::from_utf8_lossy(&self.raw_output)
);
}
self.update(Duration::from_millis(50).min(remaining));
};
let drain_deadline = Instant::now() + drain_timeout;
let mut last_output_at = Instant::now();
loop {
let remaining = drain_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(exit_code);
}
match self.pump_one(Duration::from_millis(50).min(remaining)) {
PtyPump::Chunk => last_output_at = Instant::now(),
PtyPump::Closed => return Ok(exit_code),
PtyPump::Timeout if last_output_at.elapsed() >= Duration::from_millis(200) => {
return Ok(exit_code);
}
PtyPump::Timeout => {}
}
}
}
/// Child PID (see [`PtyController::child_pid`]).
pub fn child_pid(&self) -> Option<u32> {
self.pty.child_pid()
}
/// Deliver a signal to the child (unix). See [`PtyController::send_signal`].
#[cfg(unix)]
pub fn send_signal(&self, signal: i32) -> Result<()> {
self.pty.send_signal(signal)
}
}
@@ -0,0 +1,435 @@
//! Layer 1: PTY management — spawn, inject keys, resize, drain output.
use std::io::{Read, Write};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
/// Raw key byte constants for terminal input injection.
pub mod keys {
pub const J: &[u8] = b"j";
pub const K: &[u8] = b"k";
pub const Q: &[u8] = b"q";
pub const DOWN: &[u8] = b"\x1b[B";
pub const UP: &[u8] = b"\x1b[A";
pub const PGDN: &[u8] = b"\x1b[6~";
pub const PGUP: &[u8] = b"\x1b[5~";
pub const ENTER: &[u8] = b"\r";
pub const CTRL_C: &[u8] = b"\x03";
/// Ctrl+R (0x12) — prompt history search / scrollback mouse-reporting toggle.
pub const CTRL_R: &[u8] = b"\x12";
pub const ESC: &[u8] = b"\x1b";
}
#[derive(Debug)]
pub(crate) enum PtyRead {
Chunk(Vec<u8>),
Timeout,
Closed,
}
/// Low-level PTY controller: spawns a child process inside a PTY and provides
/// methods to inject input, resize, and drain output.
pub struct PtyController {
child: Box<dyn portable_pty::Child + Send>,
writer: Box<dyn Write + Send>,
reader_rx: mpsc::Receiver<Vec<u8>>,
#[allow(dead_code)] // Kept alive to hold the PTY open; used by resize().
master: Box<dyn portable_pty::MasterPty + Send>,
}
impl PtyController {
/// Spawn a binary inside a PTY with the given terminal size.
///
/// `env` is a list of `(key, value)` pairs to set on the child process.
pub fn spawn(
binary: &Path,
size: PtySize,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Self> {
Self::spawn_in_dir(binary, size, args, env, None)
}
/// Like [`spawn`](Self::spawn), with an optional child working directory.
pub fn spawn_in_dir(
binary: &Path,
size: PtySize,
args: &[&str],
env: &[(&str, &str)],
cwd: Option<&Path>,
) -> Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(size)?;
let mut cmd = CommandBuilder::new(binary);
for arg in args {
cmd.arg(*arg);
}
if let Some(dir) = cwd {
cmd.cwd(dir);
}
apply_child_env(&mut cmd, env);
let child = pair.slave.spawn_command(cmd)?;
// Drop the slave so we get EOF when the child exits.
drop(pair.slave);
let reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
let reader_rx = spawn_reader(reader);
Ok(Self {
child,
writer,
reader_rx,
master: pair.master,
})
}
/// Write raw key bytes into the PTY stdin.
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
self.writer
.write_all(keys)
.context("failed to write to PTY stdin")
}
/// Resize the PTY (sends SIGWINCH to the child). Arguments are `(rows, cols)`.
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("failed to resize PTY")
}
/// Drain the reader channel, collecting all available chunks within `timeout`.
pub fn drain_output(&self, timeout: Duration) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
let deadline = std::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
match self.reader_rx.recv_timeout(remaining) {
Ok(chunk) => chunks.push(chunk),
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
chunks
}
/// Send 'q' to trigger the pager's quit handler and wait for exit.
///
/// Key injection is best-effort — the child may have already exited
/// (e.g. no ACP server), so write failures are silently ignored.
pub fn quit(&mut self) -> Result<()> {
let _ = self.inject_keys(keys::Q);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait()? {
Some(_) => return Ok(()),
None if std::time::Instant::now() >= deadline => {
self.child.kill()?;
self.child
.wait()
.context("failed to wait for pager child after kill")?;
return Ok(());
}
None => std::thread::sleep(Duration::from_millis(50)),
}
}
}
/// Receive one chunk, distinguishing timeout from reader EOF.
///
/// Processing each chunk inline preserves inter-chunk timing.
pub(crate) fn recv_chunk(&self, timeout: Duration) -> PtyRead {
match self.reader_rx.recv_timeout(timeout) {
Ok(chunk) => PtyRead::Chunk(chunk),
Err(mpsc::RecvTimeoutError::Timeout) => PtyRead::Timeout,
Err(mpsc::RecvTimeoutError::Disconnected) => PtyRead::Closed,
}
}
/// Check whether the child process is still running.
pub fn is_running(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// Poll child status once, preserving process-query errors.
pub(crate) fn try_exit_code(&mut self) -> Result<Option<u32>> {
self.child
.try_wait()
.map(|status| status.map(|status| status.exit_code()))
.context("failed to query PTY child status")
}
/// Wait up to `timeout` for the child to exit, returning its exit code
/// (`None` if it's still running at the deadline). Call once and cache the
/// result — `try_wait` reaps the child, so the status isn't re-readable.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
let deadline = std::time::Instant::now() + timeout;
loop {
match self.child.try_wait() {
Ok(Some(status)) => return Some(status.exit_code()),
Ok(None) if std::time::Instant::now() >= deadline => return None,
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(_) => return None,
}
}
}
/// Child PID, falling back to the PTY's foreground process group.
#[cfg(unix)]
pub fn child_pid(&self) -> Option<u32> {
self.child
.process_id()
.or_else(|| self.master.process_group_leader().map(|p| p as u32))
}
/// Child PID (no foreground-group fallback — ConPTY has no process groups).
#[cfg(windows)]
pub fn child_pid(&self) -> Option<u32> {
self.child.process_id()
}
/// Deliver a signal directly to the child (unix), bypassing the PTY line
/// discipline. Exercises the real SIGINT/SIGTERM/SIGHUP paths (distinct from
/// injected Ctrl+C key bytes, which are key events under raw mode).
/// Call before the child is reaped: a reaped pid can be reused.
#[cfg(unix)]
pub fn send_signal(&self, signal: i32) -> Result<()> {
let pid = self.child_pid().context("no child pid to signal")?;
// SAFETY: libc::kill has no memory-safety preconditions, and child_pid()
// only yields a positive live-child pid (never the kill(0)/kill(-1) broadcast).
let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
if rc != 0 {
return Err(anyhow::Error::from(std::io::Error::last_os_error())).context("libc::kill");
}
Ok(())
}
}
impl Drop for PtyController {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Host terminal identity markers stripped from the child environment.
///
/// The pager's terminal detection
/// (`kigi-pager-render/src/terminal/mod.rs`:
/// `detect_terminal_brand_from_env` / `detect_byobu_from_env` /
/// `detect_multiplexer_from_env` / `detect_tmux_meta_from_env`, plus
/// `embedded_editor.rs`'s `embedded_editor_from_env`) reads all of these,
/// so any one leaking from the harness's own host terminal reclassifies the
/// child: a dev running tests inside tmux leaks `TMUX` (every cell becomes
/// the remuxed profile), inside Cursor leaks `CURSOR_TRACE_ID` (checked
/// *before* `TERM_PROGRAM`, so it overrides even a test-injected brand),
/// inside nvim's `:terminal` leaks `NVIM` (clipboard OSC 52 wrapping).
/// Keep this list in sync with the detection source above.
const HOST_TERMINAL_ENV_VARS: &[&str] = &[
// Brand chain (detect_terminal_brand_from_env), in detection order.
"CURSOR_TRACE_ID",
"VSCODE_GIT_ASKPASS_MAIN",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINAL_EMULATOR",
"WEZTERM_VERSION",
"ITERM_SESSION_ID",
"ITERM_PROFILE",
"LC_TERMINAL",
"LC_TERMINAL_VERSION",
"TERM_SESSION_ID",
"KITTY_WINDOW_ID",
"ALACRITTY_SOCKET",
"TERMINATOR_UUID",
"VTE_VERSION",
"WT_SESSION",
// Multiplexer / Byobu markers (detect_multiplexer_from_env,
// detect_byobu_from_env, detect_tmux_meta_from_env).
"TMUX",
"TMUX_PANE",
"ZELLIJ",
"ZELLIJ_SESSION_NAME",
"STY",
"BYOBU_BACKEND",
"BYOBU_CONFIG_DIR",
"BYOBU_DISTRO",
"CMUX_SOCKET_PATH",
"CMUX_PANEL_ID",
"CMUX_BUNDLE_ID",
// Embedded editor markers (embedded_editor_from_env).
"NVIM",
"NVIM_LISTEN_ADDRESS",
"VIM_TERMINAL",
"INSIDE_EMACS",
];
/// Prepare the child environment: fixed `TERM`, color and host-terminal
/// hygiene strips, then the caller's `env` pairs.
///
/// Strips run BEFORE the caller env is applied, preserving the contract
/// that tests may re-inject any marker (e.g. `TERM_PROGRAM=vscode`, or a
/// fake `NVIM` socket) to simulate that host — see
/// `tests/pty_e2e/doubled_lines_out_of_band_repro.rs` in the pager crate.
fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) {
// Set TERM so the pager renders with full color support.
cmd.env("TERM", "xterm-256color");
// Strip inherited color opt-outs/overrides for the same reason: a
// leaked NO_COLOR (common in agent/CI shells) renders the pager
// colorless, making style-sensitive assertions (e.g. the selection
// highlight color swap) silently untestable on some hosts. Tests may
// re-set these via the `env` list (applied after this).
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
cmd.env_remove(color_var);
}
// Strip SSH vars inherited from the parent: the harness PTY is a
// local terminal, but `SSH_CONNECTION`/`SSH_TTY` leaking through
// makes the pager's terminal detector report SSH and disable the
// drag-drop image classifier (see
// `try_handle_dropped_paths_paste` in `agent_view.rs`).
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
cmd.env_remove(ssh_var);
}
// Neutralize parent-terminal identity bleed: agent hosts often export
// TERM_PROGRAM=ghostty/iTerm/etc. (and mux/editor markers) which make
// the child pager adopt that host's key/modifier/clipboard quirks even
// though we only set TERM above.
for term_var in HOST_TERMINAL_ENV_VARS {
cmd.env_remove(term_var);
}
for &(key, val) in env {
cmd.env(key, val);
}
}
/// Spawn a background thread that reads from the PTY master and sends
/// chunks over an `mpsc` channel. The reader is blocking (WezTerm pattern),
/// so it must live on its own thread.
fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("pty-reader".into())
.spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
}
}
})
.expect("failed to spawn pty-reader thread");
rx
}
#[cfg(test)]
mod tests {
use super::*;
/// Every host-terminal marker the pager's detection chain reads must be
/// stripped from the child env — polluted entries are seeded via
/// `cmd.env` (same `CommandBuilder` map that inherited base-env entries
/// live in, so `env_remove` takes the identical path) rather than
/// process-global `set_var` (racy under parallel tests).
#[test]
fn apply_child_env_strips_all_host_terminal_markers() {
let mut cmd = CommandBuilder::new("true");
for var in HOST_TERMINAL_ENV_VARS {
cmd.env(var, "polluted");
}
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
cmd.env(ssh_var, "polluted");
}
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
cmd.env(color_var, "polluted");
}
// Unrelated vars must survive the hygiene pass untouched.
cmd.env("KIGI_SCROLL_LOG", "/tmp/scroll.jsonl");
apply_child_env(&mut cmd, &[]);
for var in HOST_TERMINAL_ENV_VARS {
assert!(
cmd.get_env(var).is_none(),
"host terminal marker {var} leaked into the child env"
);
}
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
assert!(
cmd.get_env(ssh_var).is_none(),
"SSH marker {ssh_var} leaked into the child env"
);
}
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
assert!(
cmd.get_env(color_var).is_none(),
"color override {color_var} leaked into the child env"
);
}
assert_eq!(
cmd.get_env("TERM").and_then(|v| v.to_str()),
Some("xterm-256color")
);
assert_eq!(
cmd.get_env("KIGI_SCROLL_LOG").and_then(|v| v.to_str()),
Some("/tmp/scroll.jsonl"),
"hygiene must not touch unrelated vars"
);
}
/// The documented override contract: strips run BEFORE the caller env,
/// so tests can re-inject any marker to simulate a specific host
/// (e.g. the fake-nvim wrapper repro or the xtversion brand fixtures).
#[test]
fn apply_child_env_caller_env_overrides_survive_strips() {
let mut cmd = CommandBuilder::new("true");
cmd.env("TMUX", "/tmp/host-tmux,999,0");
cmd.env("CURSOR_TRACE_ID", "host-cursor");
apply_child_env(
&mut cmd,
&[
("TERM_PROGRAM", "vscode"),
("NVIM", "/tmp/fake-nvim.sock"),
("TERM", "xterm-kitty"),
],
);
// Host pollution is gone…
assert!(cmd.get_env("TMUX").is_none());
assert!(cmd.get_env("CURSOR_TRACE_ID").is_none());
// …but caller-injected markers (applied after strips) survive,
// including overriding the harness's own TERM default.
assert_eq!(
cmd.get_env("TERM_PROGRAM").and_then(|v| v.to_str()),
Some("vscode")
);
assert_eq!(
cmd.get_env("NVIM").and_then(|v| v.to_str()),
Some("/tmp/fake-nvim.sock")
);
assert_eq!(
cmd.get_env("TERM").and_then(|v| v.to_str()),
Some("xterm-kitty")
);
}
}
@@ -0,0 +1,266 @@
//! Aggregated benchmark results, percentile computation, baseline compare.
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::timing::FrameTiming;
/// Aggregated benchmark results for a single scenario run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchResults {
pub scenario: String,
pub total_frames: u64,
pub avg_fps: f64,
pub p50_ms: f64,
pub p99_ms: f64,
pub max_ms: f64,
pub jank_count: u64,
pub jank_rate: f64,
pub chars_per_frame_avg: f64,
}
impl BenchResults {
/// Compute aggregate statistics from per-frame timings.
///
/// `wall_time` is the total elapsed time during which frames were collected,
/// used to compute `avg_fps`. This should be measured by the caller.
pub fn from_timings(scenario: &str, timings: &[FrameTiming], wall_time: Duration) -> Self {
let total_frames = timings.len() as u64;
if timings.is_empty() {
return Self {
scenario: scenario.to_owned(),
total_frames: 0,
avg_fps: 0.0,
p50_ms: 0.0,
p99_ms: 0.0,
max_ms: 0.0,
jank_count: 0,
jank_rate: 0.0,
chars_per_frame_avg: 0.0,
};
}
let mut durations_ms: Vec<f64> = timings
.iter()
.map(|t| t.duration.as_secs_f64() * 1000.0)
.collect();
durations_ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let wall_secs = wall_time.as_secs_f64();
let avg_fps = if wall_secs > 0.0 {
total_frames as f64 / wall_secs
} else {
0.0
};
let p50_ms = percentile(&durations_ms, 50.0);
let p99_ms = percentile(&durations_ms, 99.0);
let max_ms = durations_ms.last().copied().unwrap_or(0.0);
// Jank threshold: frame time > 2x median (p50).
let jank_threshold = p50_ms * 2.0;
let jank_count = durations_ms.iter().filter(|&&d| d > jank_threshold).count() as u64;
let jank_rate = jank_count as f64 / total_frames as f64;
let total_chars: usize = timings.iter().map(|t| t.chars).sum();
let chars_per_frame_avg = total_chars as f64 / total_frames as f64;
Self {
scenario: scenario.to_owned(),
total_frames,
avg_fps,
p50_ms,
p99_ms,
max_ms,
jank_count,
jank_rate,
chars_per_frame_avg,
}
}
}
// ── Baseline comparison ────────────────────────────────────────────────────
/// Regression threshold: fail if a scenario's p99 frame time grows by more
/// than this fraction (0.15 = 15%). Matches the RFC's proposal.
pub const DEFAULT_REGRESSION_THRESHOLD: f64 = 0.15;
/// On-disk baseline schema: `{ "<scenario>": BenchResults, ... }`.
pub type Baseline = HashMap<String, BenchResults>;
/// Load a baseline file from disk.
pub fn load_baseline(path: &Path) -> Result<Baseline> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read baseline file {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse baseline file {}", path.display()))
}
/// Persist a set of results as a baseline file (overwrites if present).
pub fn write_baseline(path: &Path, results: &[BenchResults]) -> Result<()> {
let map: Baseline = results
.iter()
.map(|r| (r.scenario.clone(), r.clone()))
.collect();
let json = serde_json::to_string_pretty(&map).context("serialize baseline")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create baseline parent {}", parent.display()))?;
}
std::fs::write(path, json)
.with_context(|| format!("write baseline file {}", path.display()))?;
Ok(())
}
/// Outcome of comparing a single scenario's current run against its baseline.
#[derive(Debug, Clone)]
pub struct ScenarioRegression {
pub scenario: String,
pub baseline_p99_ms: f64,
pub current_p99_ms: f64,
pub pct_delta: f64,
}
/// Compare the given `results` against `baseline`, returning every scenario
/// whose p99 grew by more than `threshold` (as a fraction, e.g. 0.15 = 15%).
///
/// Scenarios missing from the baseline are skipped (first run of a new
/// scenario is not a regression).
pub fn compare_baseline(
results: &[BenchResults],
baseline: &Baseline,
threshold: f64,
) -> Vec<ScenarioRegression> {
let mut regressions = Vec::new();
for r in results {
let Some(b) = baseline.get(&r.scenario) else {
continue;
};
if b.p99_ms <= 0.0 {
continue;
}
let pct_delta = (r.p99_ms - b.p99_ms) / b.p99_ms;
if pct_delta > threshold {
regressions.push(ScenarioRegression {
scenario: r.scenario.clone(),
baseline_p99_ms: b.p99_ms,
current_p99_ms: r.p99_ms,
pct_delta,
});
}
}
regressions
}
/// Compute the `pct`-th percentile from a **sorted** slice of values.
///
/// `pct` must be in `[0.0, 100.0]`. The input slice must be sorted in
/// ascending order; this is enforced by debug assertion.
pub fn percentile(sorted: &[f64], pct: f64) -> f64 {
debug_assert!(
(0.0..=100.0).contains(&pct),
"percentile must be in [0.0, 100.0], got {pct}"
);
debug_assert!(
sorted.windows(2).all(|w| w[0] <= w[1]),
"input must be sorted"
);
if sorted.is_empty() {
return 0.0;
}
let idx = (pct / 100.0 * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_timings_returns_zeroed() {
let results = BenchResults::from_timings("empty", &[], Duration::from_secs(1));
assert_eq!(results.total_frames, 0);
assert_eq!(results.avg_fps, 0.0);
assert_eq!(results.p50_ms, 0.0);
assert_eq!(results.p99_ms, 0.0);
assert_eq!(results.max_ms, 0.0);
assert_eq!(results.jank_count, 0);
assert_eq!(results.chars_per_frame_avg, 0.0);
}
#[test]
fn single_frame_returns_correct_values() {
let timings = vec![FrameTiming {
duration: Duration::from_millis(16),
chars: 100,
}];
let results = BenchResults::from_timings("single", &timings, Duration::from_secs(1));
assert_eq!(results.total_frames, 1);
assert!((results.avg_fps - 1.0).abs() < 0.01);
assert!((results.p50_ms - 16.0).abs() < 0.1);
assert!((results.p99_ms - 16.0).abs() < 0.1);
assert!((results.max_ms - 16.0).abs() < 0.1);
assert_eq!(results.jank_count, 0);
assert!((results.chars_per_frame_avg - 100.0).abs() < 0.01);
}
#[test]
fn multiple_frames_statistics() {
let timings: Vec<FrameTiming> = (0..100)
.map(|i| FrameTiming {
duration: Duration::from_millis(10 + i % 5),
chars: 50,
})
.collect();
let results = BenchResults::from_timings("multi", &timings, Duration::from_secs(2));
assert_eq!(results.total_frames, 100);
assert!((results.avg_fps - 50.0).abs() < 0.01);
assert!(results.p50_ms >= 10.0 && results.p50_ms <= 14.0);
assert!(results.p99_ms >= 10.0 && results.p99_ms <= 14.0);
assert!((results.max_ms - 14.0).abs() < 0.1);
}
#[test]
fn percentile_empty_returns_zero() {
assert_eq!(percentile(&[], 50.0), 0.0);
}
#[test]
fn percentile_single_element() {
assert_eq!(percentile(&[42.0], 50.0), 42.0);
assert_eq!(percentile(&[42.0], 0.0), 42.0);
assert_eq!(percentile(&[42.0], 100.0), 42.0);
}
#[test]
fn percentile_multiple_elements() {
let sorted: Vec<f64> = (1..=100).map(|i| i as f64).collect();
// p50 of 1..=100 should be around 50
let p50 = percentile(&sorted, 50.0);
assert!((p50 - 50.0).abs() < 1.1);
// p99 should be around 99
let p99 = percentile(&sorted, 99.0);
assert!((p99 - 99.0).abs() < 1.1);
}
#[test]
fn jank_detection() {
// 9 frames at 10ms, 1 frame at 50ms (>2x median = jank)
let mut timings: Vec<FrameTiming> = (0..9)
.map(|_| FrameTiming {
duration: Duration::from_millis(10),
chars: 10,
})
.collect();
timings.push(FrameTiming {
duration: Duration::from_millis(50),
chars: 10,
});
let results = BenchResults::from_timings("jank", &timings, Duration::from_secs(1));
assert_eq!(results.jank_count, 1);
assert!((results.jank_rate - 0.1).abs() < 0.01);
}
}
@@ -0,0 +1,127 @@
//! Empty-composer Enter sends the top mid-turn queued follow-up now.
//!
//! Regression for send-now discoverability: plain Enter with text still
//! *queues*; a second bare Enter on the empty prompt is cancel-and-send — the
//! running turn is cancelled (silently: no "Turn cancelled by user" marker)
//! and the queued row runs as the next turn, arriving on the wire as a
//! standard `<user_query>` prompt with no interjection preamble.
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::wait_for_welcome;
use crate::{ContentController, PtyHarness, pager_binary};
const DEFAULT_ROWS: u16 = 50;
const DEFAULT_COLS: u16 = 120;
/// The interjection-merge preamble: send-now must never produce it.
const INTERJECTION_WIRE_PREFIX: &str = "The user sent a message while you were working";
fn slow_turn_text(sentinel: &str) -> String {
let mut s = String::from(sentinel);
for i in 0..30 {
s.push_str(&format!(" streaming{i}"));
}
s
}
fn all_user_message_blobs(content: &ContentController) -> Vec<String> {
content
.request_bodies()
.iter()
.flat_map(|b| {
let items = b["messages"].as_array().or_else(|| b["input"].as_array());
items
.into_iter()
.flatten()
.filter(|m| m["role"] == "user")
.map(|m| match m["content"].as_str() {
Some(s) => s.to_owned(),
None => m["content"].to_string(),
})
.collect::<Vec<_>>()
})
.collect()
}
/// Mid-turn queue via Enter, then empty Enter cancels the running turn and
/// runs that row as the next turn (cancel-and-send).
pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
let content = ContentController::start()
.await
.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 binary = pager_binary().context("resolve pager binary")?;
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.context("spawn pager")?;
wait_for_welcome(&mut harness).await?;
harness.inject_keys(b"go\r").context("submit prompt")?;
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.context("turn 1 streaming")?;
harness
.inject_keys(b"please also check the logs\r")
.context("queue follow-up")?;
harness
.wait_for_text("please also check the logs", Duration::from_secs(10))
.context("queued text visible")?;
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();
// 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.
harness
.wait_for_text(
"\u{276F} please also check the logs",
Duration::from_secs(15),
)
.context("promoted prompt scrollback chrome")?;
harness
.wait_for_text("TURNTWO", Duration::from_secs(40))
.context("promoted turn reply")?;
// A send-now cancel is silent: no "Turn cancelled by user" marker may
// appear between the partial turn-1 output and the promoted prompt.
if harness.contains_text("Turn cancelled by user") {
bail!(
"send-now cancel must not render a cancelled marker\n{}",
harness.screen_contents()
);
}
let users = all_user_message_blobs(&content);
let Some(promoted) = users
.iter()
.find(|u| u.contains("please also check the logs"))
else {
bail!("queued follow-up never reached the wire: {users:#?}");
};
if promoted.contains(INTERJECTION_WIRE_PREFIX) {
bail!("send-now must not use the interjection preamble: {promoted}");
}
if !promoted.contains("<user_query>") {
bail!("send-now must arrive as a standard user_query prompt: {promoted}");
}
if harness.contains_text("panicked") {
bail!("pager panicked\n{}", harness.screen_contents());
}
harness.quit().context("clean quit")?;
Ok(())
}
@@ -0,0 +1,30 @@
//! `idle_cost` — after content settles, measure frames for N seconds of
//! true idle. Catches the `needs_animation()` always-true bug: frames > 0
//! here means the pager is ticking when it shouldn't.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
const IDLE_WINDOW: Duration = Duration::from_secs(3);
pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Result<BenchResults> {
wait_for_welcome(harness).await?;
// Let the splash screen animation settle (some of the pager's intro
// screens do a brief fade/animation).
harness.update(Duration::from_secs(1));
harness.reset_timing();
let start = Instant::now();
while start.elapsed() < IDLE_WINDOW {
harness.update(Duration::from_millis(100));
if !harness.is_running() {
break;
}
}
let wall_time = start.elapsed();
Ok(harness.bench_results("idle_cost", wall_time))
}
@@ -0,0 +1,52 @@
//! `large_codeblock` — render a large syntax-highlighted Rust code block
//! and scroll through it.
//!
//! What it stresses: `syntect` highlighting cache, wrapping of long source
//! lines, ScratchBuffer copy for a single oversized entry.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
use crate::keys;
const LINES: usize = 400;
const SCROLL_KEYS: usize = 120;
const KEY_INTERVAL: Duration = Duration::from_millis(20);
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
content.set_response(build_rust_codeblock(LINES));
wait_for_welcome(harness).await?;
harness.inject_keys(b"code\r")?;
harness.wait_for_text("fn code_sample", Duration::from_secs(30))?;
harness.update(Duration::from_millis(500));
harness.reset_timing();
let start = Instant::now();
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
break;
}
}
harness.update(Duration::from_millis(250));
let wall_time = start.elapsed();
Ok(harness.bench_results("large_codeblock", wall_time))
}
fn build_rust_codeblock(lines: usize) -> String {
let mut s = String::with_capacity(lines * 60);
s.push_str("```rust\n");
for i in 0..lines {
s.push_str(&format!(
"fn code_sample_{i}(x: i32) -> i32 {{ x.wrapping_mul({i}).wrapping_add({}) }}\n",
i + 1
));
}
s.push_str("```\n");
s
}
@@ -0,0 +1,46 @@
//! `mixed_interaction` — scroll while streaming. The real-world worst case.
//!
//! What it stresses: simultaneous cache invalidation (from streaming) and
//! full viewport re-render (from scrolling). Surfaces `dirty_heights` /
//! scroll-offset interactions.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
use crate::keys;
const TARGET_WORDS: usize = 400;
const SCROLL_KEYS: usize = 80;
const KEY_INTERVAL: Duration = Duration::from_millis(40);
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
// Same payload shape as streaming_render, but we scroll through it
// while it's still arriving.
let mut body = String::from("mixed-bench ");
for i in 0..TARGET_WORDS {
body.push_str("tok");
body.push_str(&i.to_string());
body.push(' ');
}
content.set_response(body);
wait_for_welcome(harness).await?;
harness.inject_keys(b"go\r")?;
harness.wait_for_text("mixed-bench", Duration::from_secs(20))?;
harness.reset_timing();
let start = Instant::now();
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
break;
}
}
harness.update(Duration::from_millis(250));
let wall_time = start.elapsed();
Ok(harness.bench_results("mixed_interaction", wall_time))
}
@@ -0,0 +1,90 @@
//! Named scenarios that drive content into the pager and measure frame timing.
//!
//! Each scenario is a function `async fn run(&mut PtyHarness, &ContentController)`
//! returning a [`BenchResults`]. Scenarios are dispatched by name via the
//! [`Scenario`] enum for the `pty-bench` CLI and for ad-hoc test usage.
use std::time::Duration;
use anyhow::Result;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use super::{BenchResults, ContentController, PtyHarness};
pub mod empty_enter_send_now;
pub mod idle_cost;
pub mod large_codeblock;
pub mod mixed_interaction;
pub mod plan_approval_resume;
pub mod resize_storm;
pub mod scroll_stress;
pub mod streaming_render;
/// Enumerates every benchmark scenario that can be dispatched by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scenario {
/// Inject rapid `j` keys against a large pre-rendered response.
ScrollStress,
/// Stream a 2000-token response at a controlled rate.
StreamingRender,
/// Resize the PTY many times in quick succession; assert no crash.
ResizeStorm,
/// Render a large syntax-highlighted code block and scroll through it.
LargeCodeblock,
/// After content settles, measure CPU cost while idle.
IdleCost,
/// Scroll while streaming — the real-world worst case.
MixedInteraction,
}
impl Scenario {
/// Every scenario, in dispatch order.
pub const ALL: &'static [Scenario] = &[
Scenario::ScrollStress,
Scenario::StreamingRender,
Scenario::ResizeStorm,
Scenario::LargeCodeblock,
Scenario::IdleCost,
Scenario::MixedInteraction,
];
/// Stable slug used in JSON output and baseline files.
pub fn as_str(self) -> &'static str {
match self {
Scenario::ScrollStress => "scroll_stress",
Scenario::StreamingRender => "streaming_render",
Scenario::ResizeStorm => "resize_storm",
Scenario::LargeCodeblock => "large_codeblock",
Scenario::IdleCost => "idle_cost",
Scenario::MixedInteraction => "mixed_interaction",
}
}
/// Dispatch to the scenario implementation.
pub async fn run(
self,
harness: &mut PtyHarness,
content: &ContentController,
) -> Result<BenchResults> {
match self {
Scenario::ScrollStress => scroll_stress::run(harness, content).await,
Scenario::StreamingRender => streaming_render::run(harness, content).await,
Scenario::ResizeStorm => resize_storm::run(harness, content).await,
Scenario::LargeCodeblock => large_codeblock::run(harness, content).await,
Scenario::IdleCost => idle_cost::run(harness, content).await,
Scenario::MixedInteraction => mixed_interaction::run(harness, content).await,
}
}
}
/// Wait for the pager to render the initial welcome screen. All scenarios
/// that prompt / stream content rely on the pager being past startup.
pub(crate) async fn wait_for_welcome(harness: &mut PtyHarness) -> Result<()> {
// Menu label on the normal welcome (and gate menus): capital Q — see
// `kigi-tui` `views/welcome/mod.rs` (`"Quit"` in `render_menu`).
harness
.wait_for_text("Quit", Duration::from_secs(15))
.map_err(|e| anyhow::anyhow!("pager failed to reach welcome screen: {e}"))
}
@@ -0,0 +1,195 @@
//! Plan-approval chrome restored by the shell after quit + resume.
//!
//! When `exit_plan_mode` is parked and the user quits, the shell persists
//! `awaiting_plan_approval = true` in `plan_mode.json`. On `--continue` the
//! shell re-issues the `x.ai/exit_plan_mode` reverse-request — a real live ACP
//! waiter — so the pager re-shows approval chrome through its normal path with
//! no pager-side disk logic. Approving then leaves plan mode and starts the
//! implement turn.
//!
//! This FAILS without the shell re-park (PR2 product change): no reverse-request
//! reaches the resumed pager, so no approval chrome appears.
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::wait_for_welcome;
use crate::{ContentController, PtyHarness, pager_binary};
const DEFAULT_ROWS: u16 = 50;
const DEFAULT_COLS: u16 = 120;
const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
/// Distinct per-turn sentinels: turn 1 seeds the session before quit; turn 2 is
/// the implement turn the shell injects after the resumed approval is approved.
const SETUP_SENTINEL: &str = "GBT3703SETUP";
const IMPLEMENT_SENTINEL: &str = "GBT3703IMPLEMENTED";
const PLAN_BODY: &str = "\
# Plan GBT3703Repro
## Steps
1. Seed plan file on disk
2. Quit pager with the approval parked
3. Resume and expect restored approval chrome
";
/// Regression: the shell re-parks `exit_plan_mode` on resume; pressing
/// approve leaves plan mode and starts the implement turn.
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([
format!("{SETUP_SENTINEL}: drafted a plan for the user to review."),
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")?;
let binary = pager_binary().context("resolve pager binary")?;
let mut first = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
Some(project.path()),
)
.context("spawn first pager")?;
wait_for_welcome(&mut first).await?;
first.inject_keys(b"go\r").context("submit setup turn")?;
first
.wait_for_text(SETUP_SENTINEL, Duration::from_secs(30))
.context("setup turn rendered")?;
// Quit and reap BEFORE seeding so the still-live shell cannot re-persist
// and clobber the seeded state.
first.inject_keys(b"\x11").context("ctrl-q once")?;
first.update(Duration::from_millis(200));
first.inject_keys(b"\x11").context("ctrl-q confirm")?;
first.quit().context("reap first pager")?;
let seeded = seed_parked_approval(content.home()).context("seed parked approval")?;
assert!(seeded > 0, "no session dir seeded");
let mut resumed = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--continue"],
Some(project.path()),
)
.context("spawn resumed pager")?;
// The shell re-parks `exit_plan_mode` on resume, so approval chrome can open
// immediately and cover chat history. Prefer the chrome markers (product
// signal) over SETUP_SENTINEL, which may not be visible under the plan viewer.
// Without the shell re-park this times out.
resumed
.wait_for_text("request changes", WELCOME_TIMEOUT)
.context("restored approval 'request changes' after --continue")?;
resumed
.wait_for_text("quit plan", Duration::from_secs(5))
.context("restored approval 'quit plan' after resume")?;
let screen = resumed.screen_contents();
if !screen.contains("approve") {
bail!("expected approval primary action after resume\n{screen}");
}
// History was seeded before quit; plan body from disk is a stronger signal
// that the session was restored when chrome already covers the transcript.
if !screen.contains("GBT3703Repro")
&& !screen.contains(SETUP_SENTINEL)
&& !screen.contains("Seed plan file on disk")
{
bail!("expected resumed session content (plan body or setup sentinel)\n{screen}");
}
if resumed.contains_text("panicked") {
bail!("pager panicked\n{screen}");
}
// Approve: the shell leaves plan mode and injects the implement turn.
resumed.inject_keys(b"a").context("press 'a' to approve")?;
resumed
.wait_for_text(IMPLEMENT_SENTINEL, Duration::from_secs(30))
.context("approve must leave plan mode and start the implement turn")?;
resumed.quit().context("quit resumed pager")?;
Ok(())
}
/// Mark the persisted session as having a parked plan approval: write `plan.md`
/// and flip `awaiting_plan_approval` to `true` in `plan_mode.json` for every
/// session dir under the sandbox home.
fn seed_parked_approval(home: &Path) -> Result<usize> {
let sessions_root = home.join(".kigi").join("sessions");
if !sessions_root.is_dir() {
bail!(
"expected sessions under {} after first turn",
sessions_root.display()
);
}
let mut seeded = 0usize;
for cwd_ent in std::fs::read_dir(&sessions_root).context("read sessions root")? {
let cwd_ent = cwd_ent.context("cwd entry")?;
if !cwd_ent.file_type().context("ft")?.is_dir() {
continue;
}
for sess_ent in std::fs::read_dir(cwd_ent.path()).context("read cwd sessions")? {
let sess_ent = sess_ent.context("session entry")?;
if !sess_ent.file_type().context("ft")?.is_dir() {
continue;
}
let dir = sess_ent.path();
std::fs::write(dir.join("plan.md"), PLAN_BODY).context("write plan.md")?;
write_awaiting_plan_mode(&dir.join("plan_mode.json"))?;
seeded += 1;
}
}
if seeded == 0 {
bail!(
"expected at least one session dir under {}",
sessions_root.display()
);
}
Ok(seeded)
}
/// Round-trip the shell-written `plan_mode.json` and flip `awaiting_plan_approval`
/// to `true`, preserving every other field. Falls back to a fresh Active
/// snapshot if the shell wrote nothing. The shape mirrors
/// `kigi_shell::session::plan_mode::PlanModeSnapshot`; we only touch the one
/// field (robust to schema growth) rather than depend on the heavy shell crate
/// from this test-only harness.
fn write_awaiting_plan_mode(path: &Path) -> Result<()> {
let mut value: serde_json::Value = std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_else(|| {
serde_json::json!({
"state": "Active",
"was_previously_active": true,
"reminder_count": 0,
"pending_exit_reminder": false,
})
});
let obj = value
.as_object_mut()
.context("plan_mode.json must be a JSON object")?;
// Must be Active for the re-park; awaiting flag is the trigger.
obj.insert("state".into(), serde_json::Value::String("Active".into()));
obj.insert(
"awaiting_plan_approval".into(),
serde_json::Value::Bool(true),
);
std::fs::write(path, serde_json::to_vec_pretty(&value)?).context("write plan_mode.json")?;
Ok(())
}
@@ -0,0 +1,42 @@
//! `resize_storm` — resize the PTY many times in quick succession; assert
//! no crash and measure recovery.
//!
//! What it stresses: `prepare_layout` Case 1 (full width-change rebuild),
//! wrap-cache misses across every entry, resize-debounce in `event_loop.rs`.
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
const RESIZES: usize = 25;
const RESIZE_INTERVAL: Duration = Duration::from_millis(40);
pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Result<BenchResults> {
wait_for_welcome(harness).await?;
harness.reset_timing();
let start = Instant::now();
// Oscillate between a narrow and a wide layout.
for i in 0..RESIZES {
let (rows, cols) = if i % 2 == 0 { (35, 100) } else { (55, 160) };
harness.resize(rows, cols)?;
harness.update(RESIZE_INTERVAL);
if !harness.is_running() {
return Err(anyhow!("pager exited during resize_storm at iter {i}"));
}
}
// Let the pager settle.
harness.update(Duration::from_millis(500));
let wall_time = start.elapsed();
if harness.contains_text("panicked") {
return Err(anyhow!(
"pager rendered 'panicked' during resize_storm\nscreen:\n{}",
harness.screen_contents()
));
}
Ok(harness.bench_results("resize_storm", wall_time))
}
@@ -0,0 +1,65 @@
//! `scroll_stress` — inject rapid `j` keys against a large pre-rendered
//! response, measure frame timing.
//!
//! What it stresses: `render_scrolled_entries_with_scratch`, partial-entry
//! clipping (ScratchBuffer cell copy), `Buffer::diff()`.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
use crate::keys;
const LINES: usize = 500;
const SCROLL_KEYS: usize = 200;
const KEY_INTERVAL: Duration = Duration::from_millis(16);
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
// 1. Prime the mock server with a large markdown response.
content.set_response(long_markdown_response(LINES));
// 2. Wait for the pager's splash screen.
wait_for_welcome(harness).await?;
// 3. Submit a prompt so the mock inference server returns the big response.
// The pager is interactive: type a short prompt then hit Enter.
harness.inject_keys(b"go\r")?;
// 4. Wait until the streamed response is on screen.
// The response is `Lorem ipsum dolor ...` — look for a word we know
// will appear after streaming starts.
harness.wait_for_text("Lorem", Duration::from_secs(30))?;
// 5. Let the full response settle, then reset timing to start clean.
harness.update(Duration::from_millis(500));
harness.reset_timing();
// 6. Inject scroll-down keys at a fixed interval while collecting frames.
let wall_start = Instant::now();
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
break;
}
}
// Drain any straggler frames.
harness.update(Duration::from_millis(250));
let wall_time = wall_start.elapsed();
Ok(harness.bench_results("scroll_stress", wall_time))
}
/// Generate `n` lines of predictable markdown. Used both as scenario
/// payload and as a basic smoke test of the wrapping pipeline.
fn long_markdown_response(n: usize) -> String {
let mut out = String::with_capacity(n * 80);
out.push_str("# Scroll stress response\n\n");
for i in 0..n {
out.push_str(&format!(
"Line {i}: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n"
));
}
out
}
@@ -0,0 +1,53 @@
//! `streaming_render` — stream a response and measure frame timing during
//! active streaming.
//!
//! What it stresses: streaming-chunk cache invalidation, `ensure_wrapped()`
//! cache misses every generation, wave animation during running turn.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
const TARGET_WORDS: usize = 400;
const STREAM_WINDOW: Duration = Duration::from_secs(4);
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
content.set_response(build_response(TARGET_WORDS));
wait_for_welcome(harness).await?;
// Kick off the streamed response.
harness.inject_keys(b"stream\r")?;
// Wait for first delta to hit the screen so we're measuring the
// steady-state streaming path, not startup latency.
harness.wait_for_text("stream-bench", Duration::from_secs(20))?;
harness.reset_timing();
// Collect frames during the streaming window — the mock server paces
// itself naturally via HTTP/SSE; we just let the pipe drain.
let start = Instant::now();
while start.elapsed() < STREAM_WINDOW {
harness.update(Duration::from_millis(100));
if !harness.is_running() {
break;
}
}
let wall_time = start.elapsed();
Ok(harness.bench_results("streaming_render", wall_time))
}
fn build_response(words: usize) -> String {
let mut s = String::with_capacity(words * 10);
// Sentinel token that wait_for_text keys on — guaranteed to appear
// near the very start of the stream.
s.push_str("stream-bench ");
for i in 0..words {
s.push_str("word");
s.push_str(&i.to_string());
s.push(' ');
}
s
}
@@ -0,0 +1,186 @@
//! Layer 2a: Screen state tracking via `alacritty_terminal` (ptyctl).
//!
//! Parses raw PTY output through a headless terminal emulator and provides
//! queries for what the user would see on screen.
use ptyctl::styled::StyledLine;
use ptyctl::term::{ScreenOpts, ScreenOutput, SessionListener, Terminal};
/// Tracks the virtual terminal screen state by feeding raw PTY output
/// through an `alacritty_terminal`-based headless terminal (via ptyctl).
pub struct ScreenTracker {
terminal: Terminal,
/// Receives terminal-generated replies (cursor-position reports, device
/// attributes, color queries, …) the emulator emits while parsing input.
/// Drained by [`ScreenTracker::drain_responses`] so the harness can forward
/// them back to the child (real terminals answer these automatically).
pty_write_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
}
impl ScreenTracker {
/// Create a new tracker for a terminal with the given dimensions.
pub fn new(rows: u16, cols: u16) -> Self {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let listener = SessionListener::new(tx);
Self {
terminal: Terminal::new(cols, rows, listener),
pty_write_rx: rx,
}
}
/// Feed raw PTY output bytes into the terminal emulator.
pub fn feed(&mut self, bytes: &[u8]) {
self.terminal.feed(bytes);
}
/// Drain any terminal-generated replies queued while parsing fed input
/// (cursor-position reports answering `ESC[6n`, device attributes, color
/// queries, …), concatenated in order. Empty when nothing was queued.
///
/// These MUST be written back to the PTY or programs that probe the
/// terminal will hang or time out — most relevant here, the inline
/// viewport's startup cursor-position query that minimal mode depends on
/// (a timeout there downgrades `--minimal` to full-screen inline). A real
/// terminal answers automatically; the harness forwards these in
/// [`crate::PtyHarness::update`] when response forwarding is enabled.
pub fn drain_responses(&mut self) -> Vec<u8> {
let mut out = Vec::new();
while let Ok(bytes) = self.pty_write_rx.try_recv() {
out.extend_from_slice(&bytes);
}
out
}
/// Return structured screen contents (no escape codes).
pub fn output(&self) -> ScreenOutput {
self.terminal.screen_content(&ScreenOpts::default())
}
/// Return the full text contents of the screen (no escape codes).
pub fn contents(&self) -> String {
self.output().lines.join("\n")
}
/// Check whether the screen contains the given text substring.
pub fn contains(&self, text: &str) -> bool {
self.contents().contains(text)
}
/// Return the current cursor position as `(row, col)` (0-indexed, matching
/// the original vt100 convention used by existing tests).
pub fn cursor_position(&self) -> (u16, u16) {
let pos = self.terminal.cursor_position();
// ptyctl cursor is 1-indexed; the harness API is 0-indexed.
(
(pos.row as u16).saturating_sub(1),
(pos.col as u16).saturating_sub(1),
)
}
/// Resize the virtual terminal to new dimensions.
pub fn resize(&mut self, rows: u16, cols: u16) {
self.terminal.resize(cols, rows);
}
/// Return the full screen with style information for visual artifacts.
pub fn styled(&self) -> Vec<StyledLine> {
self.terminal.screen_styled(&ScreenOpts::default())
}
/// Render the current screen as an HTML document.
pub fn html(&self) -> String {
self.terminal.screen_html(&ScreenOpts::default())
}
/// Access the underlying ptyctl `Terminal` for advanced queries
/// (styled output, scrollback, terminal modes, etc.).
pub fn terminal(&self) -> &Terminal {
&self.terminal
}
/// Number of lines in the terminal's scrollback history — content that has
/// scrolled *above* the visible screen. This is where minimal mode's
/// committed conversation blocks land (printed via `insert_before`).
pub fn scrollback_count(&self) -> usize {
self.terminal.scrollback_count()
}
/// The full scrollback history as text, oldest line first.
pub fn scrollback_text(&self) -> String {
let n = self.terminal.scrollback_count();
self.terminal
.scrollback_lines(n)
.into_iter()
.map(|l| l.text)
.collect::<Vec<_>>()
.join("\n")
}
/// Scrollback history plus the visible screen, joined oldest→newest:
/// everything a user could see by scrolling up. Minimal-mode committed
/// content may be in either region depending on how much has accumulated,
/// so assertions on committed output should use this.
pub fn full_text(&self) -> String {
let sb = self.scrollback_text();
let screen = self.contents();
if sb.is_empty() {
screen
} else {
format!("{sb}\n{screen}")
}
}
/// Whether scrollback + visible screen contains `text`.
pub fn full_contains(&self, text: &str) -> bool {
self.full_text().contains(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Lines pushed above a small screen must be readable via the scrollback
/// helpers — the property minimal-mode e2e tests rely on to assert that a
/// committed block reached native scrollback.
#[test]
fn scrolled_off_lines_are_captured_by_scrollback_helpers() {
// 3-row screen; print 8 numbered lines so the first ones scroll off.
let mut s = ScreenTracker::new(3, 20);
for i in 1..=8 {
s.feed(format!("line{i}\r\n").as_bytes());
}
// The earliest lines are no longer on the visible screen…
assert!(
!s.contains("line1"),
"line1 should have scrolled off-screen"
);
// …but they are in scrollback, and full_text sees everything.
assert!(s.scrollback_count() >= 5, "expected scrolled-off history");
assert!(s.scrollback_text().contains("line1"));
assert!(s.full_contains("line1"));
assert!(s.full_contains("line8"));
}
/// A DSR cursor-position query (`ESC[6n`) must produce a forwardable reply
/// (a CPR `ESC[<row>;<col>R`) — the mechanism minimal-mode tests rely on so
/// the inline viewport's startup cursor query completes. Without forwarding,
/// `--minimal` silently downgrades to full-screen inline.
#[test]
fn drain_responses_answers_cursor_position_query() {
let mut s = ScreenTracker::new(24, 80);
// Nothing queued before any query is fed.
assert!(s.drain_responses().is_empty());
s.feed(b"\x1b[6n");
let reply = s.drain_responses();
assert!(
reply.starts_with(b"\x1b[") && reply.ends_with(b"R"),
"expected a cursor-position report, got {:?}",
String::from_utf8_lossy(&reply)
);
// Drained exactly once — no duplicate delivery on the next call.
assert!(s.drain_responses().is_empty());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
//! The cell table: terminal-class × config × gesture rows the matrix runs.
//!
//! Classes are `ScrollConfig` equivalence classes, not brands — every brand
//! sharing a profile is represented once (`from_terminal_context` in the
//! pager's `mouse.rs` is the source of truth):
//!
//! | class | env | ept | wheel_lpt | trackpad_lpt |
//! |-------|----------------------------|-----|-----------|--------------|
//! | C1 | none (harness strips) | 3 | 3 | 3 |
//! | C2 | `TERM_PROGRAM=iTerm.app` | 1 | 1 | 3 |
//! | C3 | `TERM_PROGRAM=zed` | 1 | 3 | 3 |
//! | C4 | `TERM_PROGRAM=vscode` | 1 | 3 | 15 |
//! | C5 | `TMUX=…` (remuxed) | 1 | 1 | 3 |
//!
//! Mux honesty: C5's env only exercises profile *selection* (the pager
//! can't tell a fake `TMUX` from a real one); real tmux event-mangling is
//! simulated by the G9 gesture shapes, and a real-tmux tier stays local.
//!
//! Trim note: the design sketched ~40 rows; this table ships the curated 8
//! plus 17 representative full-tier rows (every class, every gesture, every
//! config knob at least once) to hold the A12 size budget — growing the
//! full tier is additive row work.
use super::gestures::GestureId;
use super::invariants::InvariantId;
use super::session::SessionKind;
/// The config echo a cell expects on every `stream_start` (I-CFG) and the
/// pricing inputs the consistency invariants use.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExpectedProfile {
/// `mode` label: `auto` | `wheel` | `trackpad`.
pub mode: &'static str,
pub ept: u16,
pub wheel_lpt: u16,
pub trackpad_lpt: u16,
pub invert: bool,
/// Speed multiplier (NOT the 1-100 setting): `KIGI_SCROLL_SPEED=100`
/// echoes 6.0 via the pager's `speed_to_multiplier`.
pub speed: f32,
}
/// Execution tier: `Curated` runs in CI (A13's runner test); `Full` only in
/// the local full sweep.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tier {
Curated,
Full,
}
/// One matrix cell: environment in, gesture replayed, invariants judged.
#[derive(Clone, Copy, Debug)]
pub struct MatrixCell {
pub id: &'static str,
pub tier: Tier,
/// Pager env pairs (terminal-class markers + config vars). The runner
/// appends `KIGI_SCROLL_LOG`; the harness's env strips guarantee the
/// host terminal can't leak competing markers underneath these.
pub env: &'static [(&'static str, &'static str)],
pub expected: ExpectedProfile,
pub gesture: GestureId,
pub session: SessionKind,
/// Invariants judged for this cell (harness-side ids included; the
/// runner routes by `InvariantId::is_log_side`).
pub invariants: &'static [InvariantId],
/// Invariants expected to VIOLATE on current code (known bugs, e.g. the
/// G4 jerk until A13's finalize-decel fix). Must be ⊆ `invariants`; the
/// runner fails a cell on any non-xfail violation AND on an xfail PASS
/// (a fixed bug must be promoted out of xfail, not silently absorbed).
pub xfail: &'static [InvariantId],
}
const C1: ExpectedProfile = ExpectedProfile {
mode: "auto",
ept: 3,
wheel_lpt: 3,
trackpad_lpt: 3,
invert: false,
speed: 1.0,
};
const C2: ExpectedProfile = ExpectedProfile {
ept: 1,
wheel_lpt: 1,
..C1
};
const C3: ExpectedProfile = ExpectedProfile { ept: 1, ..C1 };
const C4: ExpectedProfile = ExpectedProfile {
ept: 1,
trackpad_lpt: 15,
..C1
};
/// Remuxed conservative profile — identical numbers to C2 by design
/// (`multiplexer_reencodes_mouse` forces ept=1/wheel_lpt=1).
const C5: ExpectedProfile = C2;
const ITERM: (&str, &str) = ("TERM_PROGRAM", "iTerm.app");
const ZED: (&str, &str) = ("TERM_PROGRAM", "zed");
const VSCODE: (&str, &str) = ("TERM_PROGRAM", "vscode");
const TMUX: (&str, &str) = ("TMUX", "/tmp/tmux-0/default,1,0");
const SPEED100: (&str, &str) = ("KIGI_SCROLL_SPEED", "100");
const MODE_WHEEL: (&str, &str) = ("KIGI_SCROLL_MODE", "wheel");
const MODE_TRACKPAD: (&str, &str) = ("KIGI_SCROLL_MODE", "trackpad");
const LINES1: (&str, &str) = ("KIGI_SCROLL_LINES", "1");
const INVERT: (&str, &str) = ("KIGI_INVERT_SCROLL", "1");
use InvariantId::*;
/// Core log-side suite for auto-mode cells.
const AUTO: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg];
const AUTO_NODROP: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop];
/// Floods keep the core suite (drops are legitimate) + post-gesture quiet.
const AUTO_QUIET: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Quiet];
/// Forced wheel: exact totals; nothing may drop on notch-scale gestures.
const WHEEL: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop];
const WHEEL_MUX: &[InvariantId] = &[
Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop, MuxNoOver,
];
const AUTO_MUX_NODROP: &[InvariantId] = &[
Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop, MuxNoOver,
];
const AUTO_SCREEN: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Screen];
/// The jerk suite: core + the two smoothness invariants the finalize-decel
/// fix made hold (formerly this cell's xfail set).
const JERK: &[InvariantId] = &[
Ord,
Cap,
DropEq,
Cadence,
ConsA,
Accel,
Carry,
Cfg,
SmoothCoast,
NoDrop,
];
const fn cell(
id: &'static str,
tier: Tier,
env: &'static [(&'static str, &'static str)],
expected: ExpectedProfile,
gesture: GestureId,
session: SessionKind,
invariants: &'static [InvariantId],
) -> MatrixCell {
MatrixCell {
id,
tier,
env,
expected,
gesture,
session,
invariants,
xfail: &[],
}
}
/// The matrix. Ids are `<class>_<config>_<gesture>[_qualifier]`.
#[rustfmt::skip]
pub const CELLS: &[MatrixCell] = &[
// ── Curated (CI tier, 8 cells) ─────────────────────────────────────
cell("c1_auto_g3_flood_speed100", Tier::Curated, &[SPEED100],
ExpectedProfile { speed: 6.0, ..C1 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c2_auto_g3_flood_speed100", Tier::Curated, &[ITERM, SPEED100],
ExpectedProfile { speed: 6.0, ..C2 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c3_wheel_lines1_g1", Tier::Curated, &[ZED, MODE_WHEEL, LINES1],
ExpectedProfile { mode: "wheel", wheel_lpt: 1, trackpad_lpt: 1, ..C3 },
GestureId::G1Notch, SessionKind::Settled, WHEEL),
cell("c4_auto_g10_ambiguous", Tier::Curated, &[VSCODE],
C4, GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c5_tmux_g9a", Tier::Curated, &[TMUX],
C5, GestureId::G9aMuxSingles, SessionKind::Settled, AUTO_MUX_NODROP),
cell("c5_tmux_g9b", Tier::Curated, &[TMUX],
C5, GestureId::G9bMuxBatch, SessionKind::Settled, AUTO_MUX_NODROP),
cell("c1_auto_g8_midstream", Tier::Curated, &[],
C1, GestureId::G8MidStreamTrain, SessionKind::Streaming, AUTO),
// Id kept for artifact/test continuity: the cell pinned the G4 jerk as
// xfail until the finalize-decel fix; its former xfail rows
// (I-SMOOTH-COAST, I-NO-DROP) are ordinary pass rows now.
cell("c1_auto_g4_jerk_xfail", Tier::Curated, &[],
C1, GestureId::G4Jerk, SessionKind::Settled, JERK),
// ── Full tier (local sweep; representative subset — see trim note) ─
cell("c1_auto_g1", Tier::Full, &[], C1,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g2", Tier::Full, &[], C1,
GestureId::G2NotchTrain, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g5_ghostty_dup", Tier::Full, &[], C1,
GestureId::G5GhosttyDup, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g6_flip", Tier::Full, &[], C1,
GestureId::G6Flip, SessionKind::Settled, AUTO),
cell("c1_auto_g7_overscroll", Tier::Full, &[], C1,
GestureId::G7Overscroll, SessionKind::BottomPinned, AUTO_SCREEN),
cell("c1_wheel_g2", Tier::Full, &[MODE_WHEEL], ExpectedProfile { mode: "wheel", ..C1 },
GestureId::G2NotchTrain, SessionKind::Settled, WHEEL),
cell("c1_trackpad_g3_flood", Tier::Full, &[MODE_TRACKPAD],
ExpectedProfile { mode: "trackpad", ..C1 },
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c1_auto_g11_carry", Tier::Full, &[], C1,
GestureId::G11Carry, SessionKind::Settled, AUTO_NODROP),
cell("c1_invert_g1", Tier::Full, &[INVERT], ExpectedProfile { invert: true, ..C1 },
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c2_auto_g1", Tier::Full, &[ITERM], C2,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c2_wheel_g1", Tier::Full, &[ITERM, MODE_WHEEL],
ExpectedProfile { mode: "wheel", ..C2 },
GestureId::G1Notch, SessionKind::Settled, WHEEL),
cell("c3_auto_g1", Tier::Full, &[ZED], C3,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c3_auto_g10_repricing", Tier::Full, &[ZED], C3,
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c4_auto_g3_flood", Tier::Full, &[VSCODE], C4,
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c4_lines1_g10", Tier::Full, &[VSCODE, LINES1],
ExpectedProfile { wheel_lpt: 1, trackpad_lpt: 1, ..C4 },
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c5_wheel_g9b", Tier::Full, &[TMUX, MODE_WHEEL],
ExpectedProfile { mode: "wheel", ..C5 },
GestureId::G9bMuxBatch, SessionKind::Settled, WHEEL_MUX),
cell("c5_auto_g6_flip", Tier::Full, &[TMUX], C5,
GestureId::G6Flip, SessionKind::Settled, AUTO),
];
/// The CI subset.
pub fn curated() -> impl Iterator<Item = &'static MatrixCell> {
CELLS.iter().filter(|c| c.tier == Tier::Curated)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Re-derive the expected profile from a cell's env pairs the way the
/// pager would (`from_terminal_context` + the `KIGI_SCROLL_*` env
/// overrides) — a tripwire against rows drifting from `mouse.rs`.
fn derive_expected(env: &'static [(&'static str, &'static str)]) -> ExpectedProfile {
let get = |k: &str| env.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let remuxed = get("TMUX").is_some();
let (ept, mut wheel_lpt, mut trackpad_lpt) = if remuxed {
(1, 1, 3)
} else {
match get("TERM_PROGRAM") {
Some("iTerm.app") => (1, 1, 3),
Some("zed") => (1, 3, 3),
Some("vscode") => (1, 3, 15),
None => (3, 3, 3),
Some(other) => panic!("unmapped TERM_PROGRAM {other:?}"),
}
};
if let Some(lines) = get("KIGI_SCROLL_LINES") {
let lines: u16 = lines.parse().unwrap();
wheel_lpt = lines;
trackpad_lpt = lines; // one knob overrides both paths
}
// speed_to_multiplier re-derivation for the settings used in rows.
let speed = match get("KIGI_SCROLL_SPEED") {
None | Some("50") => 1.0,
Some("100") => 6.0,
Some(other) => panic!("unmapped KIGI_SCROLL_SPEED {other:?}"),
};
ExpectedProfile {
mode: get("KIGI_SCROLL_MODE").unwrap_or("auto"),
ept,
wheel_lpt,
trackpad_lpt,
invert: get("KIGI_INVERT_SCROLL") == Some("1"),
speed,
}
}
#[test]
fn ids_are_unique() {
let mut seen = HashSet::new();
for cell in CELLS {
assert!(seen.insert(cell.id), "duplicate cell id {}", cell.id);
}
}
#[test]
fn curated_tier_is_the_designed_eight() {
let ids: Vec<&str> = curated().map(|c| c.id).collect();
assert_eq!(
ids,
[
"c1_auto_g3_flood_speed100",
"c2_auto_g3_flood_speed100",
"c3_wheel_lines1_g1",
"c4_auto_g10_ambiguous",
"c5_tmux_g9a",
"c5_tmux_g9b",
"c1_auto_g8_midstream",
"c1_auto_g4_jerk_xfail",
]
);
}
#[test]
fn xfail_is_a_subset_of_the_cell_invariants() {
for cell in CELLS {
for id in cell.xfail {
assert!(
cell.invariants.contains(id),
"{}: xfail {} not in the invariant list",
cell.id,
id.as_str()
);
}
}
// The finalize-decel fix promoted the jerk cell's xfail rows
// (I-SMOOTH-COAST, I-NO-DROP) into ordinary pass rows: the table
// must carry no xfail anywhere until the next pinned bug.
let jerk = CELLS
.iter()
.find(|c| c.id == "c1_auto_g4_jerk_xfail")
.unwrap();
assert!(
jerk.xfail.is_empty(),
"the jerk is fixed; xfail must stay empty"
);
assert!(
[InvariantId::SmoothCoast, InvariantId::NoDrop]
.iter()
.all(|id| jerk.invariants.contains(id)),
"the promoted invariants must remain in the pass set"
);
}
#[test]
fn expected_profiles_agree_with_env() {
for cell in CELLS {
assert_eq!(
cell.expected,
derive_expected(cell.env),
"{}: expected profile drifted from its env",
cell.id
);
}
}
#[test]
fn invariant_lists_are_coherent() {
for cell in CELLS {
let mut seen = HashSet::new();
for id in cell.invariants {
assert!(seen.insert(id), "{}: duplicate {}", cell.id, id.as_str());
}
// Consistency invariants match the forced mode.
assert_eq!(
cell.invariants.contains(&InvariantId::ConsW),
cell.expected.mode == "wheel",
"{}: I-CONS-W ⇔ forced wheel",
cell.id
);
assert_eq!(
cell.invariants.contains(&InvariantId::ConsA),
cell.expected.mode != "wheel",
"{}: I-CONS-A ⇔ not forced wheel",
cell.id
);
// Mux over-scroll bound only makes sense on the remuxed class,
// and only on its accel-free G9 gestures.
if cell.invariants.contains(&InvariantId::MuxNoOver) {
assert!(
matches!(
cell.gesture,
GestureId::G9aMuxSingles | GestureId::G9bMuxBatch
) && cell.env.iter().any(|(k, _)| *k == "TMUX"),
"{}: I-MUX-NO-OVER outside the mux/G9 envelope",
cell.id
);
}
}
}
#[test]
fn sessions_and_gestures_pair_correctly() {
for cell in CELLS {
// TMUX ⇒ the conservative remuxed profile.
if cell.env.iter().any(|(k, _)| *k == "TMUX") {
assert_eq!(
(cell.expected.ept, cell.expected.wheel_lpt),
(1, 1),
"{}",
cell.id
);
}
// Streaming sessions exist exactly for the mid-stream gesture;
// the bottom-pin matters exactly for the overscroll gesture.
assert_eq!(
cell.session == SessionKind::Streaming,
cell.gesture == GestureId::G8MidStreamTrain,
"{}",
cell.id
);
assert_eq!(
cell.session == SessionKind::BottomPinned,
cell.gesture == GestureId::G7Overscroll,
"{}",
cell.id
);
}
}
}
@@ -0,0 +1,371 @@
//! Gesture step tables G1G11: the timed SGR wheel-report shapes every
//! matrix cell replays.
//!
//! A gesture is a `&'static [WheelStep]`; the A13 runner emits each step's
//! report after sleeping `pre_delay_ms` (never before the first step, never
//! after the last — the `send_wheel_sequence` contract in the pager's
//! `tests/pty_e2e/scroll.rs`). Delays are HOST-side lower bounds: scheduler
//! jitter can only stretch gaps, which is why the invariant suite
//! ([`super::invariants`]) judges timing from the recorder's own clock.
//!
//! Timing thresholds below mirror the pager's `src/input/mouse.rs` (the
//! harness deliberately has no pager dependency — same drift-tripwire
//! stance as [`super::log`]): a table's shape is meaningful only relative
//! to those constants, e.g. G2's 50ms notch gap sits under `STREAM_GAP_MS`
//! (one stream) but over `WHEEL_TICK_DETECT_MAX_MS` (no wheel promotion of
//! the train as one tick).
use crate::scripted::{SGR_SCROLL_DOWN, SGR_SCROLL_UP};
/// Mirror of `mouse.rs` `REDRAW_CADENCE_MS`: minimum flush spacing.
pub const REDRAW_CADENCE_MS: u64 = 16;
/// Mirror of `mouse.rs` `STREAM_GAP_MS`: idle gap that finalizes a stream.
pub const STREAM_GAP_MS: u64 = 80;
/// Mirror of `mouse.rs` `DEFAULT_WHEEL_TICK_DETECT_MAX_MS`: an ept≥2 stream
/// promotes to wheel only when the first tick completes within this window.
pub const WHEEL_TICK_DETECT_MAX_MS: u64 = 12;
/// Mirror of `mouse.rs` `ACCEL_MIN_INTERVAL_MS`: sub-6ms inter-event
/// intervals are terminal batching artifacts and stay out of the
/// accel/detection interval window.
pub const ACCEL_MIN_INTERVAL_MS: f64 = 6.0;
/// Mirror of `mouse.rs` `DEFAULT_TRACKPAD_ACCEL_MAX`: accel clamp ceiling.
pub const TRACKPAD_ACCEL_MAX: f64 = 3.0;
/// Mirror of `mouse.rs` `MIN_LINES_PER_WHEEL_STREAM`.
pub const MIN_LINES_PER_WHEEL_STREAM: i64 = 1;
/// One SGR wheel report: sleep `pre_delay_ms`, then emit `button`.
///
/// `button` is [`SGR_SCROLL_UP`]/[`SGR_SCROLL_DOWN`] — `u16` because those
/// harness consts are `u16` (the design sketch said `u8`; deviating keeps
/// one shared definition instead of a cast at every emission site).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelStep {
/// Host-side sleep before emitting this report (0 for the first step).
pub pre_delay_ms: u64,
/// SGR wheel button code (64 up / 65 down).
pub button: u16,
}
/// `count` same-direction reports, `interval_ms` between consecutive ones.
const fn burst<const N: usize>(interval_ms: u64, button: u16) -> [WheelStep; N] {
let mut steps = [WheelStep {
pre_delay_ms: 0,
button,
}; N];
let mut i = 1;
while i < N {
steps[i].pre_delay_ms = interval_ms;
i += 1;
}
steps
}
/// Notches of `events_per_notch` back-to-back reports, `notch_gap_ms` apart.
const fn notch_train<const N: usize>(
events_per_notch: usize,
notch_gap_ms: u64,
button: u16,
) -> [WheelStep; N] {
let mut steps = burst::<N>(0, button);
let mut i = events_per_notch;
while i < N {
if i.is_multiple_of(events_per_notch) {
steps[i].pre_delay_ms = notch_gap_ms;
}
i += 1;
}
steps
}
/// G1 single notch, ept=3 brands: 3 back-to-back reports (first tick lands
/// inside the 12ms window → wheel promotion in Auto mode).
pub const G1_NOTCH_EPT3: [WheelStep; 3] = burst::<3>(0, SGR_SCROLL_UP);
/// G1 single notch, ept=1 brands (iTerm2/zed/vscode/mux): one report.
pub const G1_NOTCH_EPT1: [WheelStep; 1] = burst::<1>(0, SGR_SCROLL_UP);
/// G2 notch train: 5 notches 50ms apart (< `STREAM_GAP_MS` → one stream).
pub const G2_NOTCH_TRAIN_EPT3: [WheelStep; 15] = notch_train::<15>(3, 50, SGR_SCROLL_UP);
pub const G2_NOTCH_TRAIN_EPT1: [WheelStep; 5] = burst::<5>(50, SGR_SCROLL_UP);
/// G3 flood: 60 back-to-back reports (cap/pacing exercise — A4 teleport).
pub const G3_FLOOD: [WheelStep; 60] = burst::<60>(0, SGR_SCROLL_UP);
/// G4 jerk repro: a 3-event anti-promotion head at 8ms, 57 dense reports,
/// then a decelerating 6-event tail (gaps growing 40→70ms, all under the
/// 80ms stream gap).
///
/// Two shape details make the repro real under a PTY (verified against the
/// live recorder):
/// - **The head.** Back-to-back writes arrive batched, so a fully dense
/// burst completes its first ept=3 tick inside the 12ms window and
/// promotes to WHEEL pricing — which never re-prices at finalize and
/// never jerks. The 8ms head lands the first tick past the window, so
/// the stream stays Unknown (priced ~1 line/event, accel window seeded
/// in the fast band).
/// - **The 40ms+ tail gaps.** They open cadence slots with no new events
/// while the dense backlog is still draining: capped `events_since_flush
/// == 0` coast flushes (the I-SMOOTH-COAST signature). Tighter gaps ride
/// every slot and mask the coast.
///
/// At the gap finalize the Unknown→Trackpad re-price (accel-weighted,
/// ~2.5× the mid-stream pricing) then bursts one more capped flush and
/// drops the rest — the I-NO-DROP half of the jerk (xfail cell until the
/// finalize-decel fix).
pub const G4_JERK: [WheelStep; 66] = {
let mut steps = burst::<66>(0, SGR_SCROLL_UP);
steps[1].pre_delay_ms = 8;
steps[2].pre_delay_ms = 8;
let tail = [40, 44, 50, 55, 60, 70];
let mut i = 0;
while i < tail.len() {
steps[60 + i].pre_delay_ms = tail[i];
i += 1;
}
steps
};
/// G5 ghostty dup: 10 notches 60ms apart, each report duplicated 4ms later
/// (ghostty emits ≥2 SGR reports per physical notch ~4ms apart). The 4ms
/// dups sit under `ACCEL_MIN_INTERVAL_MS` and must stay out of the
/// interval window — the I-ACCEL G5 clause.
pub const G5_GHOSTTY_DUP: [WheelStep; 20] = {
let mut steps = burst::<20>(0, SGR_SCROLL_UP);
let mut i = 1;
while i < 20 {
steps[i].pre_delay_ms = if i % 2 == 1 { 4 } else { 60 };
i += 1;
}
steps
};
/// G6 flip: 10×8ms up then 10×8ms down — the direction flip finalizes
/// stream 1 and opens stream 2 at the same instant (two streams).
pub const G6_FLIP: [WheelStep; 20] = {
let mut steps = burst::<20>(8, SGR_SCROLL_UP);
let mut i = 10;
while i < 20 {
steps[i].button = SGR_SCROLL_DOWN;
i += 1;
}
steps
};
/// G7 overscroll: bottom-pinned 10×8ms down (viewport must clamp — the
/// harness-side I-SCREEN check) then 3 up (must move again). Two streams.
pub const G7_OVERSCROLL: [WheelStep; 13] = {
let mut steps = burst::<13>(8, SGR_SCROLL_DOWN);
let mut i = 10;
while i < 13 {
steps[i].button = SGR_SCROLL_UP;
i += 1;
}
steps
};
/// G9a mux re-chunk 1:1: 8 single reports 55ms apart (tmux re-emitting one
/// event per notch; 55ms > the 30ms ept=1 trackpad-detect window → never
/// promotes to trackpad mid-stream).
pub const G9A_MUX_SINGLES: [WheelStep; 8] = burst::<8>(55, SGR_SCROLL_UP);
/// G9b mux re-chunk batch: 8 notches 55ms apart × 3 back-to-back events
/// (tmux passing through an inner ept=3 chunking, re-timed).
pub const G9B_MUX_BATCH: [WheelStep; 24] = notch_train::<24>(3, 55, SGR_SCROLL_UP);
/// G10 ambiguous slow roll: 12 reports 40ms apart — inside the vscode-embed
/// 60ms trackpad-detect window, outside the default 30ms one.
pub const G10_AMBIGUOUS_SLOW: [WheelStep; 12] = burst::<12>(40, SGR_SCROLL_UP);
/// G11 carry: one notch, a 120ms wait (> `STREAM_GAP_MS` → finalize), one
/// notch — the sub-line carry handoff across same-direction streams.
pub const G11_CARRY_EPT3: [WheelStep; 6] = notch_train::<6>(3, 120, SGR_SCROLL_UP);
pub const G11_CARRY_EPT1: [WheelStep; 2] = burst::<2>(120, SGR_SCROLL_UP);
/// Gesture identifier a [`super::cells::MatrixCell`] references.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GestureId {
G1Notch,
G2NotchTrain,
G3Flood,
G4Jerk,
G5GhosttyDup,
G6Flip,
G7Overscroll,
/// G2's step table replayed while the turn is still streaming
/// (`SessionKind::Streaming`); the shape lives in the session, not here.
G8MidStreamTrain,
G9aMuxSingles,
G9bMuxBatch,
G10AmbiguousSlow,
G11Carry,
}
impl GestureId {
/// Every gesture, for exhaustive table sweeps (tests, the A13 runner).
pub const ALL: [GestureId; 12] = [
GestureId::G1Notch,
GestureId::G2NotchTrain,
GestureId::G3Flood,
GestureId::G4Jerk,
GestureId::G5GhosttyDup,
GestureId::G6Flip,
GestureId::G7Overscroll,
GestureId::G8MidStreamTrain,
GestureId::G9aMuxSingles,
GestureId::G9bMuxBatch,
GestureId::G10AmbiguousSlow,
GestureId::G11Carry,
];
/// Step table for this gesture on a brand with `ept` events per notch.
/// Only the notch-based gestures (G1/G2/G8/G11) vary by class; the rest
/// are fixed event shapes (G9b is deliberately 3-per-notch even on the
/// ept=1 mux profile — it simulates the mux passing re-chunked input).
pub fn steps(self, ept: u16) -> &'static [WheelStep] {
let ept3 = ept >= 2;
match self {
GestureId::G1Notch => {
if ept3 {
&G1_NOTCH_EPT3
} else {
&G1_NOTCH_EPT1
}
}
GestureId::G2NotchTrain | GestureId::G8MidStreamTrain => {
if ept3 {
&G2_NOTCH_TRAIN_EPT3
} else {
&G2_NOTCH_TRAIN_EPT1
}
}
GestureId::G3Flood => &G3_FLOOD,
GestureId::G4Jerk => &G4_JERK,
GestureId::G5GhosttyDup => &G5_GHOSTTY_DUP,
GestureId::G6Flip => &G6_FLIP,
GestureId::G7Overscroll => &G7_OVERSCROLL,
GestureId::G9aMuxSingles => &G9A_MUX_SINGLES,
GestureId::G9bMuxBatch => &G9B_MUX_BATCH,
GestureId::G10AmbiguousSlow => &G10_AMBIGUOUS_SLOW,
GestureId::G11Carry => {
if ept3 {
&G11_CARRY_EPT3
} else {
&G11_CARRY_EPT1
}
}
}
}
/// Streams (finalize records) this gesture produces: 1, plus one per
/// direction flip or >`STREAM_GAP_MS` intra-gesture pause. The A13
/// runner's `wait_for_finalize_count` target.
pub fn expected_streams(self) -> usize {
match self {
GestureId::G6Flip | GestureId::G7Overscroll | GestureId::G11Carry => 2,
_ => 1,
}
}
}
/// `(up, down)` report counts — direction-sum test primitive.
pub fn direction_counts(steps: &[WheelStep]) -> (usize, usize) {
let up = steps.iter().filter(|s| s.button == SGR_SCROLL_UP).count();
(up, steps.len() - up)
}
#[cfg(test)]
mod tests {
use super::*;
/// Streams split on gaps > STREAM_GAP_MS or direction flips — recompute
/// from the table and compare to the declared `expected_streams`.
fn streams_in(steps: &[WheelStep]) -> usize {
let mut streams = 1;
for pair in steps.windows(2) {
if pair[1].pre_delay_ms > STREAM_GAP_MS || pair[1].button != pair[0].button {
streams += 1;
}
}
streams
}
#[test]
fn table_counts_and_direction_sums() {
assert_eq!(G1_NOTCH_EPT3.len(), 3);
assert_eq!(G1_NOTCH_EPT1.len(), 1);
assert_eq!(G2_NOTCH_TRAIN_EPT3.len(), 15);
assert_eq!(G2_NOTCH_TRAIN_EPT1.len(), 5);
assert_eq!(G3_FLOOD.len(), 60);
assert_eq!(G4_JERK.len(), 66);
assert_eq!(G5_GHOSTTY_DUP.len(), 20);
assert_eq!(G9B_MUX_BATCH.len(), 24);
assert_eq!(G10_AMBIGUOUS_SLOW.len(), 12);
assert_eq!(direction_counts(&G3_FLOOD), (60, 0));
assert_eq!(direction_counts(&G6_FLIP), (10, 10), "flip nets to zero");
assert_eq!(direction_counts(&G7_OVERSCROLL), (3, 10));
assert_eq!(direction_counts(&G9A_MUX_SINGLES), (8, 0));
}
#[test]
fn notch_structure_and_gaps() {
// G2/ept3: notch starts every 3 events carry the 50ms gap, intra-notch 0.
for (i, step) in G2_NOTCH_TRAIN_EPT3.iter().enumerate() {
let expected = if i > 0 && i % 3 == 0 { 50 } else { 0 };
assert_eq!(step.pre_delay_ms, expected, "G2 ept3 step {i}");
}
// G9b: same shape at 55ms — under the 80ms gap, one stream.
let notch_gaps = G9B_MUX_BATCH
.iter()
.filter(|s| s.pre_delay_ms == 55)
.count();
assert_eq!(notch_gaps, 7, "8 notches → 7 inter-notch gaps");
// G5: dup 4ms after each notch head, notch heads 60ms apart.
for (i, step) in G5_GHOSTTY_DUP.iter().enumerate() {
let expected = if i == 0 {
0
} else if i % 2 == 1 {
4
} else {
60
};
assert_eq!(step.pre_delay_ms, expected, "G5 step {i}");
}
}
#[test]
fn jerk_head_blocks_promotion_and_tail_decays_monotonically() {
// Anti-promotion head: the first ept=3 tick must complete strictly
// after the 12ms wheel-promotion window even with zero jitter
// (sleeps only stretch), or PTY batching wheel-promotes the burst
// and the finalize re-price under test never happens.
let head_span: u64 = G4_JERK[..3].iter().map(|s| s.pre_delay_ms).sum();
assert!(head_span > WHEEL_TICK_DETECT_MAX_MS);
let tail: Vec<u64> = G4_JERK[60..].iter().map(|s| s.pre_delay_ms).collect();
assert_eq!(tail, vec![40, 44, 50, 55, 60, 70]);
assert!(
tail.windows(2).all(|w| w[0] < w[1]),
"strictly decelerating"
);
// Coast window: every tail gap opens ≥2 empty 16ms cadence slots so
// the dense backlog drains as events_since_flush == 0 flushes.
assert!(tail.iter().all(|&gap| gap >= 2 * REDRAW_CADENCE_MS));
assert!(G4_JERK[3..60].iter().all(|s| s.pre_delay_ms == 0));
}
#[test]
fn delays_agree_with_stream_gap_thresholds() {
// Single-stream gestures never pause past the 80ms finalize gap and
// never flip; multi-stream ones split exactly as declared.
for gesture in GestureId::ALL {
for ept in [1u16, 3] {
let steps = gesture.steps(ept);
assert!(!steps.is_empty());
assert_eq!(steps[0].pre_delay_ms, 0, "{gesture:?} first step");
assert_eq!(
streams_in(steps),
gesture.expected_streams(),
"{gesture:?} ept={ept}: table shape vs declared stream count"
);
}
}
// The G11 pause is what splits it: strictly past the finalize gap.
assert!(G11_CARRY_EPT3[3].pre_delay_ms > STREAM_GAP_MS);
assert!(G11_CARRY_EPT1[1].pre_delay_ms > STREAM_GAP_MS);
// G1/ept3 is a first tick inside the wheel-promotion window.
let g1_span: u64 = G1_NOTCH_EPT3.iter().map(|s| s.pre_delay_ms).sum();
assert!(g1_span <= WHEEL_TICK_DETECT_MAX_MS);
// G5's dup spacing must sit under the interval-window floor.
assert!((G5_GHOSTTY_DUP[1].pre_delay_ms as f64) < ACCEL_MIN_INTERVAL_MS);
}
}
@@ -0,0 +1,800 @@
//! The invariant suite: predicates over grouped `KIGI_SCROLL_LOG` streams.
//!
//! Stall-safety: every timing predicate reads the RECORDER's clock
//! (`ts_ms`, `ms_since_prev_flush`, `avg_interval_ms`), never the test
//! process's — CI load can stretch host-side gesture delays but can only
//! ever *widen* the producer-measured spacings, so no invariant here can
//! false-fail on a loaded machine.
//!
//! Two invariants are declared here but checked by the A13 matrix runner,
//! not this module: [`InvariantId::Screen`] (the viewport visibly
//! moved/clamped — needs `PtyHarness` marker positions) and
//! [`InvariantId::Quiet`] (no repaint churn after finalize — needs the
//! harness frame watermark). They exist in the id enum so cells can declare
//! them and the runner can route by [`InvariantId::is_log_side`];
//! [`check_log_invariant`] panics if asked to evaluate them.
use super::cells::ExpectedProfile;
use super::gestures::{
ACCEL_MIN_INTERVAL_MS, MIN_LINES_PER_WHEEL_STREAM, REDRAW_CADENCE_MS, TRACKPAD_ACCEL_MAX,
};
use super::log::{ScrollLogLine, StreamGroup};
/// Float slack for f32-serialized fields (accel/speed/carry comparisons).
const F32_TOLERANCE: f64 = 0.01;
/// Invariant identifier; `as_str` is the design's I-* vocabulary.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InvariantId {
/// I-ORD: `ts_ms` is non-decreasing across the capture (flip
/// boundaries legitimately share a timestamp).
Ord,
/// I-CAP: every record's `|flushed| ≤ cap` — the A4 teleport guard.
Cap,
/// I-DROP-EQ: finalize carries `dropped == backlog_after` (the producer
/// constructs it that way; a mismatch is producer drift).
DropEq,
/// I-CADENCE: intra-stream flush spacings ≥ `REDRAW_CADENCE_MS 1` —
/// the A2 busy-spin guard. Skips `promotion`-triggered records (they
/// flush immediately by design) and `finalize` records (the finalize
/// flush deliberately ignores the cadence gate — `finalize_stream_at`
/// in `mouse.rs` — and a flip finalize can land mid-slot; a gap
/// finalize is ≥80ms anyway, so nothing real is lost). The design
/// sketch listed only the promotion skip; the finalize skip is the
/// code-verified correction.
Cadence,
/// I-CONS-W: forced-wheel totals are exact —
/// `|applied+dropped| == trunc(events × wheel_lpt/ept × speed)` ±1,
/// with the `MIN_LINES_PER_WHEEL_STREAM` substitution when the raw
/// pricing truncates to zero. Wheel pricing never includes carry.
ConsW,
/// I-CONS-A: auto/trackpad totals are bounded —
/// per-event pricing lies in `[min(wheel_lpt/ept, tp_lpt/3),
/// max(wheel_lpt/ept, ACCEL_MAX × tp_lpt/3)] × speed` (trackpad divisor
/// is the normalized 3, accel ceiling 3.0 = `trackpad_accel_max`;
/// effective accel tops at 2.5 so the bound is loose-but-sound). Checks
/// the finalize's `desired` within `[lo, hi]` and the delivered
/// `|applied+dropped| ≤ hi`; ±1 slack absorbs carry/trunc.
ConsA,
/// I-ACCEL: `1.0 ≤ accel ≤ 3.0` on every record, and `avg_interval_ms`,
/// when present, is ≥ `ACCEL_MIN_INTERVAL_MS` (6). The G5 clause: even
/// ghostty-style 4ms duplicate reports must never drag the average
/// under 6 — the producer excludes sub-6ms intervals from the window,
/// so a lower value means that artifact guard regressed.
Accel,
/// I-CARRY: `|carry| < 1.0` everywhere (only sub-line remainders ride
/// across streams), and a wheel-kind finalize zeroes it — the NEXT
/// `stream_start` must echo `carry == 0`. The log carries no direction,
/// but the machine zeroes carry at wheel finalize and resets it on
/// direction change, so the next-start check holds for both same- and
/// opposite-direction successors (direction-agnostic strengthening of
/// the design's same-direction phrasing).
Carry,
/// I-CFG: the `stream_start` config echo matches the cell's expected
/// profile (mode/ept/wheel_lpt/trackpad_lpt/invert/speed) — the env →
/// profile plumbing witness.
Cfg,
/// I-MUX-NO-OVER: delivered total per stream ≤ `events × speed + 1` —
/// the conservative remuxed profile (ept=1/wheel_lpt=1) prices at most
/// one line per event. Attach only to accel-free gestures (>20ms
/// spacing — exactly 20ms still interpolates to 1.6× in the accel band;
/// G9's 55ms clears it): a fast trackpad-classified mux stream may
/// legitimately exceed it via accel.
MuxNoOver,
/// I-SMOOTH-COAST: per stream, `Σ|flushed|` over flush-bearing records
/// with `events_since_flush == 0` ≤ cap — motion delivered after input
/// stopped is at most one capped catch-up. The jerk's coast-drain +
/// finalize re-price burst exceeds it (xfail until A13's decel fix).
SmoothCoast,
/// I-NO-DROP: every finalize has `dropped == 0`. Attach to gestures the
/// cap can keep up with; floods legitimately drop.
NoDrop,
/// I-SCREEN (harness-side, A13): viewport marker delta matches the
/// gesture — moved on scroll, clamped at the bottom pin (G7).
Screen,
/// I-QUIET (harness-side, A13): frame watermark stays put after the
/// last finalize — no post-gesture repaint churn (A2's symptom).
Quiet,
}
impl InvariantId {
/// Design vocabulary label.
pub fn as_str(self) -> &'static str {
match self {
InvariantId::Ord => "I-ORD",
InvariantId::Cap => "I-CAP",
InvariantId::DropEq => "I-DROP-EQ",
InvariantId::Cadence => "I-CADENCE",
InvariantId::ConsW => "I-CONS-W",
InvariantId::ConsA => "I-CONS-A",
InvariantId::Accel => "I-ACCEL",
InvariantId::Carry => "I-CARRY",
InvariantId::Cfg => "I-CFG",
InvariantId::MuxNoOver => "I-MUX-NO-OVER",
InvariantId::SmoothCoast => "I-SMOOTH-COAST",
InvariantId::NoDrop => "I-NO-DROP",
InvariantId::Screen => "I-SCREEN",
InvariantId::Quiet => "I-QUIET",
}
}
/// Whether [`check_log_invariant`] can evaluate this id from the log
/// alone. `false` = harness-side (screen/frame state), owned by the
/// A13 runner.
pub fn is_log_side(self) -> bool {
!matches!(self, InvariantId::Screen | InvariantId::Quiet)
}
}
/// Verdict of one invariant over one cell's captured streams.
#[derive(Clone, Debug, PartialEq)]
pub enum InvariantResult {
Pass,
Violated { detail: String },
}
impl InvariantResult {
pub fn is_pass(&self) -> bool {
matches!(self, InvariantResult::Pass)
}
}
fn violated(detail: String) -> InvariantResult {
InvariantResult::Violated { detail }
}
/// All records of all groups, in capture order.
fn records<'a>(groups: &'a [StreamGroup<'a>]) -> impl Iterator<Item = &'a ScrollLogLine> {
groups.iter().flat_map(|g| {
std::iter::once(g.start)
.chain(g.flushes.iter().copied())
.chain(g.finalize)
})
}
/// Signed delivered total of a finalized stream: applied + discarded.
fn delivered(finalize: &ScrollLogLine) -> i64 {
finalize.applied_total + finalize.dropped.unwrap_or(0)
}
/// Evaluate one log-side invariant. Panics on harness-side ids
/// ([`InvariantId::is_log_side`] == false) — routing those here is a
/// runner bug, not a cell verdict.
pub fn check_log_invariant(
id: InvariantId,
expected: &ExpectedProfile,
groups: &[StreamGroup<'_>],
) -> InvariantResult {
match id {
InvariantId::Ord => check_ord(groups),
InvariantId::Cap => check_cap(groups),
InvariantId::DropEq => check_drop_eq(groups),
InvariantId::Cadence => check_cadence(groups),
InvariantId::ConsW => check_cons_w(expected, groups),
InvariantId::ConsA => check_cons_a(expected, groups),
InvariantId::Accel => check_accel(groups),
InvariantId::Carry => check_carry(groups),
InvariantId::Cfg => check_cfg(expected, groups),
InvariantId::MuxNoOver => check_mux_no_over(expected, groups),
InvariantId::SmoothCoast => check_smooth_coast(groups),
InvariantId::NoDrop => check_no_drop(groups),
InvariantId::Screen | InvariantId::Quiet => panic!(
"{} is harness-side (needs PtyHarness screen/frame state); the A13 matrix \
runner checks it — route by InvariantId::is_log_side",
id.as_str()
),
}
}
fn check_ord(groups: &[StreamGroup<'_>]) -> InvariantResult {
let mut prev = f64::NEG_INFINITY;
for rec in records(groups) {
if rec.ts_ms < prev {
return violated(format!(
"ts_ms went backwards: {} after {prev} ({} record)",
rec.ts_ms, rec.evt
));
}
prev = rec.ts_ms;
}
InvariantResult::Pass
}
fn check_cap(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
if rec.flushed.abs() > rec.cap {
return violated(format!(
"flushed {} exceeds cap {} at ts_ms={} (trigger {})",
rec.flushed, rec.cap, rec.ts_ms, rec.trigger
));
}
}
InvariantResult::Pass
}
fn check_drop_eq(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
match fin.dropped {
Some(dropped) if dropped == fin.backlog_after => {}
Some(dropped) => {
return violated(format!(
"finalize at ts_ms={} has dropped={dropped} but backlog_after={}",
fin.ts_ms, fin.backlog_after
));
}
None => {
return violated(format!("finalize at ts_ms={} lacks dropped", fin.ts_ms));
}
}
}
InvariantResult::Pass
}
fn check_cadence(groups: &[StreamGroup<'_>]) -> InvariantResult {
// A stream's first flush-bearing record is skipped: its spacing is
// global (measured from the previous stream — see
// `intra_stream_flush_spacings_ms`).
let floor = (REDRAW_CADENCE_MS - 1) as f64;
for group in groups {
for rec in group.flush_bearing().skip(1) {
if rec.trigger == "promotion" || rec.trigger == "finalize" {
continue;
}
if let Some(spacing) = rec.ms_since_prev_flush
&& spacing < floor
{
return violated(format!(
"{}ms flush spacing < {floor}ms at ts_ms={} (trigger {})",
spacing, rec.ts_ms, rec.trigger
));
}
}
}
InvariantResult::Pass
}
fn check_cons_w(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
if expected.mode != "wheel" {
return violated(format!(
"I-CONS-W attached to a mode={} cell (needs forced wheel)",
expected.mode
));
}
let rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
for group in groups {
let Some(fin) = group.finalize else { continue };
let raw = (fin.events_total as f64 * rate * f64::from(expected.speed)).trunc() as i64;
let want = if fin.events_total > 0 {
raw.max(MIN_LINES_PER_WHEEL_STREAM)
} else {
0
};
let got = delivered(fin).abs();
if (got - want).abs() > 1 {
return violated(format!(
"wheel stream at ts_ms={}: delivered {got} lines for {} events, expected {want}±1",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_cons_a(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
if expected.mode == "wheel" {
return violated("I-CONS-A attached to a forced-wheel cell (use I-CONS-W)".into());
}
let wheel_rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
let tp_rate = f64::from(expected.trackpad_lpt) / 3.0;
// Forced trackpad never prices via the wheel table.
let (min_rate, max_rate) = if expected.mode == "trackpad" {
(tp_rate, TRACKPAD_ACCEL_MAX * tp_rate)
} else {
(
wheel_rate.min(tp_rate),
wheel_rate.max(TRACKPAD_ACCEL_MAX * tp_rate),
)
};
for group in groups {
let Some(fin) = group.finalize else { continue };
let events = fin.events_total as f64;
let speed = f64::from(expected.speed);
let lo = events * min_rate * speed - 1.0;
let hi = events * max_rate * speed + 1.0;
let desired = f64::from(fin.desired).abs();
if desired < lo || desired > hi {
return violated(format!(
"stream at ts_ms={}: |desired|={desired:.2} outside [{lo:.2}, {hi:.2}] \
for {} events",
fin.ts_ms, fin.events_total
));
}
let got = delivered(fin).abs() as f64;
if got > hi {
return violated(format!(
"stream at ts_ms={}: delivered {got} lines > bound {hi:.2} for {} events",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_accel(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
let accel = f64::from(rec.accel);
if !(1.0 - F32_TOLERANCE..=TRACKPAD_ACCEL_MAX + F32_TOLERANCE).contains(&accel) {
return violated(format!(
"accel {accel} outside [1.0, {TRACKPAD_ACCEL_MAX}] at ts_ms={}",
rec.ts_ms
));
}
if let Some(avg) = rec.avg_interval_ms
&& avg < ACCEL_MIN_INTERVAL_MS - F32_TOLERANCE
{
return violated(format!(
"avg_interval_ms {avg} < 6 at ts_ms={} — sub-6ms batching artifacts \
(ghostty dups) leaked into the interval window",
rec.ts_ms
));
}
}
InvariantResult::Pass
}
fn check_carry(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
if f64::from(rec.carry).abs() >= 1.0 {
return violated(format!(
"|carry| {} ≥ 1.0 at ts_ms={} — whole lines leaked across streams",
rec.carry, rec.ts_ms
));
}
}
for pair in groups.windows(2) {
let Some(fin) = pair[0].finalize else {
continue;
};
if fin.kind == "wheel" && f64::from(pair[1].start.carry).abs() > F32_TOLERANCE {
return violated(format!(
"stream_start at ts_ms={} carries {} after a wheel finalize (must be 0)",
pair[1].start.ts_ms, pair[1].start.carry
));
}
}
InvariantResult::Pass
}
fn check_cfg(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let start = group.start;
let echo = (
start.mode.as_deref(),
start.ept,
start.wheel_lpt,
start.trackpad_lpt,
start.invert,
);
let want = (
Some(expected.mode),
Some(expected.ept),
Some(expected.wheel_lpt),
Some(expected.trackpad_lpt),
Some(expected.invert),
);
let speed_ok = start
.speed
.is_some_and(|s| (f64::from(s) - f64::from(expected.speed)).abs() <= F32_TOLERANCE);
if echo != want || !speed_ok {
return violated(format!(
"config echo at ts_ms={} is {:?} speed={:?}, cell expects \
mode={} ept={} wheel_lpt={} trackpad_lpt={} invert={} speed={}",
start.ts_ms,
echo,
start.speed,
expected.mode,
expected.ept,
expected.wheel_lpt,
expected.trackpad_lpt,
expected.invert,
expected.speed,
));
}
}
InvariantResult::Pass
}
fn check_mux_no_over(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
let bound = fin.events_total as f64 * f64::from(expected.speed) + 1.0;
let got = delivered(fin).abs() as f64;
if got > bound {
return violated(format!(
"remuxed stream at ts_ms={}: delivered {got} lines for {} events \
(> {bound:.2} — over-scroll on the conservative mux profile)",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_smooth_coast(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let coast: i64 = group
.flush_bearing()
.filter(|rec| rec.events_since_flush == 0)
.map(|rec| rec.flushed.abs())
.sum();
let cap = group
.flush_bearing()
.map(|rec| rec.cap)
.max()
.unwrap_or(i64::MAX);
if coast > cap {
return violated(format!(
"stream at ts_ms={}: {coast} coast lines (flushes with no new events) \
> cap {cap} — post-input motion beyond one capped catch-up (the jerk)",
group.start.ts_ms
));
}
}
InvariantResult::Pass
}
fn check_no_drop(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
let dropped = fin.dropped.unwrap_or(0);
if dropped != 0 {
return violated(format!(
"finalize at ts_ms={} dropped {dropped} lines",
fin.ts_ms
));
}
}
InvariantResult::Pass
}
#[cfg(test)]
mod tests {
use super::super::log::{group_streams, parse_jsonl_str};
use super::*;
// ── JSONL fixture builders ─────────────────────────────────────────
// Raw strings through parse_jsonl_str so every fixture also exercises
// the wire schema (same stance as log.rs's producer-shaped constants).
const C1: ExpectedProfile = ExpectedProfile {
mode: "auto",
ept: 3,
wheel_lpt: 3,
trackpad_lpt: 3,
invert: false,
speed: 1.0,
};
const C1_WHEEL: ExpectedProfile = ExpectedProfile {
mode: "wheel",
..C1
};
// Floats are formatted with `{:?}` so whole values keep their decimal
// point (`32.0`, not `32`) — the mutation `.replace()`s below match on
// that spelling, and it mirrors serde_json's f64 output for round
// numbers.
fn start(ts: f64, carry: f32, mode: &str, speed: f32) -> String {
format!(
r#"{{"ts_ms":{ts:?},"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":{carry:?},"cap":25,"mode":"{mode}","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":{speed:?},"viewport_height":50}}"#
)
}
#[allow(clippy::too_many_arguments)]
fn row(
evt: &str,
ts: f64,
trigger: &str,
kind: &str,
events: (u64, u64),
accel_avg: (f32, Option<f64>),
lines: (f32, i64, i64, i64), // desired, applied_total, flushed, backlog_after
msf: Option<f64>,
dropped: Option<i64>,
) -> String {
let avg = accel_avg
.1
.map_or(String::new(), |v| format!(r#""avg_interval_ms":{v:?},"#));
let msf = msf.map_or(String::new(), |v| {
format!(r#","ms_since_prev_flush":{v:?}"#)
});
let dropped = dropped.map_or(String::new(), |v| format!(r#","dropped":{v}"#));
format!(
r#"{{"ts_ms":{ts:?},"evt":"{evt}","trigger":"{trigger}","kind":"{kind}","events_total":{},"events_since_flush":{},{avg}"accel":{:?},"desired":{:?},"applied_total":{},"flushed":{},"backlog_after":{},"carry":0.0,"cap":25{msf}{dropped}}}"#,
events.0, events.1, accel_avg.0, lines.0, lines.1, lines.2, lines.3,
)
}
fn check(id: InvariantId, expected: &ExpectedProfile, jsonl: &[String]) -> InvariantResult {
let raw = jsonl.join("\n");
let records = parse_jsonl_str(&raw).expect("fixture must parse");
let groups = group_streams(&records).expect("fixture must group");
check_log_invariant(id, expected, &groups)
}
fn assert_violated(result: InvariantResult, needle: &str) {
match result {
InvariantResult::Violated { detail } => {
assert!(
detail.contains(needle),
"detail {detail:?} lacks {needle:?}"
);
}
InvariantResult::Pass => panic!("expected violation mentioning {needle:?}"),
}
}
/// Canonical clean capture: a trackpad stream (sub-line carry out), a
/// wheel stream (carry zeroed), a third stream echoing that zero.
/// One record per line (rustfmt::skip) — reads like the JSONL it builds.
#[rustfmt::skip]
fn canonical() -> Vec<String> {
vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "trackpad", (6, 6), (1.0, Some(10.0)), (6.4, 6, 6, 0), None, None),
row("flush", 32.0, "tick", "trackpad", (9, 3), (1.0, Some(10.0)), (9.4, 9, 3, 0), Some(16.0), None),
row("finalize", 120.0, "finalize", "trackpad", (9, 0), (1.0, Some(10.0)), (9.4, 9, 0, 0), Some(88.0), Some(0)),
start(500.0, 0.4, "auto", 1.0),
row("flush", 505.0, "promotion", "wheel", (3, 3), (1.0, None), (3.0, 3, 3, 0), Some(385.0), None),
row("finalize", 600.0, "finalize", "wheel", (3, 0), (1.0, None), (3.0, 3, 0, 0), Some(95.0), Some(0)),
start(700.0, 0.0, "auto", 1.0),
row("flush", 716.0, "event", "unknown", (6, 6), (1.0, Some(8.0)), (6.2, 6, 6, 0), Some(116.0), None),
row("finalize", 800.0, "finalize", "trackpad", (6, 0), (1.0, Some(8.0)), (6.2, 6, 0, 0), Some(84.0), Some(0)),
]
}
/// The real G4 signature: coast drain + finalize re-price burst with a
/// drop — mid-stream priced as unknown (~1 line/event), re-priced ×2.5
/// at the trackpad finalize, backlog beyond one capped flush discarded.
#[rustfmt::skip]
fn jerk() -> Vec<String> {
vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "unknown", (40, 40), (2.5, Some(6.0)), (40.0, 25, 25, 15), None, None),
row("flush", 32.0, "tick", "unknown", (66, 26), (2.5, Some(6.0)), (66.0, 50, 25, 16), Some(16.0), None),
row("flush", 48.0, "tick", "unknown", (66, 0), (2.5, Some(6.0)), (66.0, 66, 16, 0), Some(16.0), None),
row("finalize", 146.0, "finalize", "trackpad", (66, 0), (2.5, Some(6.0)), (165.0, 91, 25, 74), Some(98.0), Some(74)),
]
}
#[test]
fn every_log_side_invariant_passes_on_the_canonical_capture() {
// All log-side ids except I-CONS-W, which requires a forced-wheel
// profile and has its own pass fixture.
let fixture = canonical();
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
InvariantId::MuxNoOver,
InvariantId::SmoothCoast,
InvariantId::NoDrop,
] {
let result = check(id, &C1, &fixture);
assert!(result.is_pass(), "{} on canonical: {result:?}", id.as_str());
}
}
#[test]
fn ord_rejects_backwards_timestamps() {
let mut fixture = canonical();
fixture[2] = fixture[2].replace(r#""ts_ms":32.0"#, r#""ts_ms":12.0"#);
assert_violated(check(InvariantId::Ord, &C1, &fixture), "backwards");
}
/// A4 teleport: one flush delivering more than the per-flush cap.
#[test]
fn cap_rejects_over_cap_flush() {
let mut fixture = canonical();
fixture[1] = fixture[1].replace(r#""flushed":6"#, r#""flushed":40"#);
assert_violated(check(InvariantId::Cap, &C1, &fixture), "exceeds cap");
}
#[test]
fn drop_eq_rejects_mismatched_finalize_accounting() {
let mut fixture = canonical();
fixture[3] = fixture[3].replace(r#""backlog_after":0,"#, r#""backlog_after":9,"#);
fixture[3] = fixture[3].replace(r#""dropped":0"#, r#""dropped":5"#);
assert_violated(check(InvariantId::DropEq, &C1, &fixture), "dropped=5");
}
/// A2 busy-spin: an 8ms tick-flush spacing violates; the same spacing on
/// a promotion-triggered record is skipped (promotion flushes bypass the
/// cadence gate by design), as are finalize records (flip finalizes).
#[test]
fn cadence_rejects_sub_16ms_spacing_but_skips_promotion_and_finalize() {
let mut fixture = canonical();
fixture[2] = fixture[2].replace(
r#""ms_since_prev_flush":16.0"#,
r#""ms_since_prev_flush":8.0"#,
);
assert_violated(check(InvariantId::Cadence, &C1, &fixture), "8ms");
let mut skipped = canonical();
skipped[2] = skipped[2]
.replace(r#""trigger":"tick""#, r#""trigger":"promotion""#)
.replace(
r#""ms_since_prev_flush":16.0"#,
r#""ms_since_prev_flush":8.0"#,
);
// Flip-style finalize 8ms after the previous flush: also skipped.
skipped[3] = skipped[3].replace(
r#""ms_since_prev_flush":88.0"#,
r#""ms_since_prev_flush":8.0"#,
);
assert!(check(InvariantId::Cadence, &C1, &skipped).is_pass());
}
/// A3 under-travel: a forced-wheel stream delivering two lines short.
#[test]
fn cons_w_exact_totals_with_min_lines_substitution() {
// 6 events × (3/3) × 1.0 = 6 lines, delivered exactly; a second
// 1-event stream prices to 1.0 → still ≥ the MIN_LINES floor.
#[rustfmt::skip]
let pass = vec![
start(0.0, 0.0, "wheel", 1.0),
row("flush", 16.0, "event", "wheel", (6, 6), (1.0, None), (6.0, 6, 6, 0), None, None),
row("finalize", 100.0, "finalize", "wheel", (6, 0), (1.0, None), (6.0, 6, 0, 0), Some(84.0), Some(0)),
start(300.0, 0.0, "wheel", 1.0),
row("finalize", 400.0, "finalize", "wheel", (1, 1), (1.0, None), (1.0, 1, 1, 0), Some(300.0), Some(0)),
];
assert!(check(InvariantId::ConsW, &C1_WHEEL, &pass).is_pass());
let mut short = pass.clone();
short[1] = short[1].replace(
r#""applied_total":6,"flushed":6"#,
r#""applied_total":4,"flushed":4"#,
);
short[2] = short[2].replace(r#""applied_total":6"#, r#""applied_total":4"#);
assert_violated(check(InvariantId::ConsW, &C1_WHEEL, &short), "expected 6±1");
// Attached to a non-wheel cell = cell-table bug, not a pass.
assert_violated(check(InvariantId::ConsW, &C1, &pass), "needs forced wheel");
}
#[test]
fn cons_a_bounds_auto_totals() {
// 3 events on the C1 profile can never desire 100 lines
// (hi = 3 × max(1, 3×1) × 1 + 1 = 10).
let mut fixture = canonical();
fixture[6] = fixture[6].replace(r#""desired":3.0"#, r#""desired":100.0"#);
assert_violated(check(InvariantId::ConsA, &C1, &fixture), "outside");
assert_violated(
check(InvariantId::ConsA, &C1_WHEEL, &canonical()),
"use I-CONS-W",
);
}
/// G5 clause: a sub-6ms average means ghostty-style duplicate reports
/// leaked into the interval window; out-of-range accel is the same
/// regression on the multiplier side.
#[test]
fn accel_rejects_out_of_band_multiplier_and_sub_6ms_average() {
let mut fixture = canonical();
fixture[1] = fixture[1].replace(r#""accel":1.0"#, r#""accel":4.5"#);
assert_violated(check(InvariantId::Accel, &C1, &fixture), "accel 4.5");
let mut dup = canonical();
dup[1] = dup[1].replace(r#""avg_interval_ms":10.0"#, r#""avg_interval_ms":3.0"#);
assert_violated(check(InvariantId::Accel, &C1, &dup), "interval window");
}
#[test]
fn carry_rejects_whole_line_carry_and_nonzero_start_after_wheel_finalize() {
let mut fixture = canonical();
fixture[4] = start(500.0, 1.4, "auto", 1.0);
assert_violated(check(InvariantId::Carry, &C1, &fixture), "≥ 1.0");
// Wheel finalize (stream 2) must zero the carry into stream 3.
let mut leak = canonical();
leak[7] = start(700.0, 0.4, "auto", 1.0);
assert_violated(check(InvariantId::Carry, &C1, &leak), "wheel finalize");
}
#[test]
fn cfg_rejects_echo_profile_mismatch() {
assert!(check(InvariantId::Cfg, &C1, &canonical()).is_pass());
let mut fixture = canonical();
fixture[0] = start(0.0, 0.0, "auto", 6.0); // speed echo ≠ expected 1.0
assert_violated(check(InvariantId::Cfg, &C1, &fixture), "speed");
let expected_wheel = ExpectedProfile {
mode: "wheel",
..C1
};
assert_violated(
check(InvariantId::Cfg, &expected_wheel, &canonical()),
"mode",
);
}
/// A4-class over-scroll on the conservative remuxed profile: more
/// delivered lines than events at speed 1.0.
#[test]
fn mux_no_over_rejects_over_delivery() {
let mut fixture = canonical();
fixture[3] = fixture[3].replace(r#""applied_total":9"#, r#""applied_total":20"#);
// Keep DropEq-independent: only this invariant is under test.
assert_violated(check(InvariantId::MuxNoOver, &C1, &fixture), "over-scroll");
}
/// The jerk fixture violates exactly the two xfail invariants of
/// `c1_auto_g4_jerk_xfail` — and nothing else in the core suite, which
/// is what confines the expected failure to those rows.
#[test]
fn jerk_shape_violates_smooth_coast_and_no_drop_only() {
let fixture = jerk();
assert_violated(check(InvariantId::SmoothCoast, &C1, &fixture), "coast");
assert_violated(check(InvariantId::NoDrop, &C1, &fixture), "dropped 74");
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
] {
let result = check(id, &C1, &fixture);
assert!(result.is_pass(), "{} on jerk: {result:?}", id.as_str());
}
}
/// One capped catch-up flush after input stops is legitimate (that is
/// the finalize contract) — coast ≤ cap passes.
#[test]
fn smooth_coast_allows_a_single_capped_catchup() {
#[rustfmt::skip]
let fixture = vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "unknown", (30, 30), (1.0, Some(6.0)), (30.0, 25, 25, 5), None, None),
row("finalize", 100.0, "finalize", "trackpad", (30, 0), (1.0, Some(6.0)), (30.0, 30, 5, 0), Some(84.0), Some(0)),
];
assert!(check(InvariantId::SmoothCoast, &C1, &fixture).is_pass());
assert!(check(InvariantId::NoDrop, &C1, &fixture).is_pass());
}
#[test]
#[should_panic(expected = "harness-side")]
fn harness_side_ids_panic_in_the_log_checker() {
let _ = check_log_invariant(InvariantId::Screen, &C1, &[]);
}
#[test]
fn log_side_partition_matches_the_a13_split() {
for id in [InvariantId::Screen, InvariantId::Quiet] {
assert!(!id.is_log_side(), "{}", id.as_str());
}
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsW,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
InvariantId::MuxNoOver,
InvariantId::SmoothCoast,
InvariantId::NoDrop,
] {
assert!(id.is_log_side(), "{}", id.as_str());
}
}
}
@@ -0,0 +1,502 @@
//! `KIGI_SCROLL_LOG` JSONL parsing, per-stream grouping, and finalize
//! synchronization.
//!
//! Wire schema source of truth: the pager's `ScrollLogRecord` in
//! `kigi-tui/src/input/scroll_log.rs`. [`ScrollLogLine`] mirrors it
//! field-for-field with every always-emitted field **required** (see the
//! module docs in [`super`] for the drift-tripwire rationale).
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// `evt` value of a stream-start record (config echo rides these only).
pub const EVT_STREAM_START: &str = "stream_start";
/// `evt` value of a mid-stream line-delivering flush record.
pub const EVT_FLUSH: &str = "flush";
/// `evt` value of a stream-finalize record (`dropped` rides these only).
pub const EVT_FINALIZE: &str = "finalize";
/// One parsed flight-recorder line.
///
/// Field names and required/optional split mirror the producer's
/// `ScrollLogRecord` (`scroll_log.rs`): the producer always emits the
/// non-`Option` fields, `#[serde(skip_serializing_if)]`s the `Option`
/// bookkeeping fields, and `#[serde(flatten)]`s the config echo onto
/// `stream_start` records only. Unknown fields are tolerated (additive
/// producer changes must not break older matrix code); missing required
/// fields fail loudly with the line number via [`parse_jsonl`].
#[derive(Debug, Clone, Deserialize)]
pub struct ScrollLogLine {
/// Monotonic ms since recorder start (the state machine's timeline).
pub ts_ms: f64,
/// Record type: [`EVT_STREAM_START`] | [`EVT_FLUSH`] | [`EVT_FINALIZE`].
pub evt: String,
/// Emitting code path: `event` | `tick` | `promotion` | `finalize`.
pub trigger: String,
/// Stream classification (`unknown` until promotion/finalize).
pub kind: String,
/// Events accumulated in the stream so far.
pub events_total: u64,
/// Arrivals since the last logged flush/finalize of this stream.
pub events_since_flush: u64,
/// Acceleration multiplier in effect.
pub accel: f32,
/// Fractional target lines (post accel/speed multipliers, carry included).
pub desired: f32,
/// Whole lines delivered for this stream so far (post-flush).
pub applied_total: i64,
/// Lines this record's flush delivered (0 on stream_start).
pub flushed: i64,
/// Whole-line backlog remaining after this record's flush.
pub backlog_after: i64,
/// Sub-line remainder included in `desired`.
pub carry: f32,
/// Per-flush delta cap in effect.
pub cap: i64,
/// Rolling average inter-event interval (ms); absent until two
/// accel-countable events arrived.
pub avg_interval_ms: Option<f64>,
/// Spacing from the previous flush-bearing record; absent before the
/// first. **Global, not per-stream** — the producer's `last_flush_at`
/// never resets at stream boundaries, so on a stream's first
/// flush-bearing record this measures from the *previous stream*. Use
/// [`StreamGroup::intra_stream_flush_spacings_ms`] for per-stream
/// cadence.
pub ms_since_prev_flush: Option<f64>,
/// Finalize only: whole lines discarded with the stream.
pub dropped: Option<i64>,
// Config echo, flattened onto stream_start records only.
/// Scroll input mode label in effect (`auto` | `wheel` | `trackpad`).
pub mode: Option<String>,
/// Events per tick.
pub ept: Option<u16>,
/// Wheel lines per tick.
pub wheel_lpt: Option<u16>,
/// Trackpad lines per tick.
pub trackpad_lpt: Option<u16>,
/// Direction inversion.
pub invert: Option<bool>,
/// Speed multiplier.
pub speed: Option<f32>,
/// Viewport height stamped on the config.
pub viewport_height: Option<u16>,
}
impl ScrollLogLine {
/// Whether this is a stream-start record.
pub fn is_stream_start(&self) -> bool {
self.evt == EVT_STREAM_START
}
/// Whether this is a mid-stream flush record.
pub fn is_flush(&self) -> bool {
self.evt == EVT_FLUSH
}
/// Whether this is a finalize record.
pub fn is_finalize(&self) -> bool {
self.evt == EVT_FINALIZE
}
}
/// Parse a `KIGI_SCROLL_LOG` JSONL file into records.
///
/// Every line must parse — errors carry the 1-based line number and the
/// offending line. Call after the capture is quiescent (the producer
/// force-flushes on finalize, so a file whose last gesture finalized ends
/// on a record boundary); mid-write reads can see a torn tail line, which
/// fails here by design — synchronize with [`wait_for_finalize_count`]
/// first.
pub fn parse_jsonl(path: &Path) -> Result<Vec<ScrollLogLine>> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read scroll log {}", path.display()))?;
parse_jsonl_str(&raw).with_context(|| format!("scroll log {}", path.display()))
}
/// [`parse_jsonl`] over an in-memory JSONL string (fixtures, pre-read files).
pub fn parse_jsonl_str(raw: &str) -> Result<Vec<ScrollLogLine>> {
raw.lines()
.enumerate()
.map(|(idx, line)| {
serde_json::from_str(line)
.with_context(|| format!("line {}: failed to parse {line:?}", idx + 1))
})
.collect()
}
/// One recorded gesture: `stream_start` → `flush`* → `finalize`.
///
/// `finalize` is `None` only for a trailing stream still in flight when the
/// capture ended: finalize rides the 80ms-gap and direction-flip
/// transitions, so a capture cut mid-gesture leaves the last stream open.
#[derive(Debug, Clone)]
pub struct StreamGroup<'a> {
/// The `stream_start` record (carries the config echo).
pub start: &'a ScrollLogLine,
/// Mid-stream `flush` records, in file order.
pub flushes: Vec<&'a ScrollLogLine>,
/// The `finalize` record; `None` for a trailing in-flight stream.
pub finalize: Option<&'a ScrollLogLine>,
}
impl<'a> StreamGroup<'a> {
/// Whether the stream's finalize record was captured.
pub fn is_finalized(&self) -> bool {
self.finalize.is_some()
}
/// Flush-bearing records (flushes, then finalize if present), in order.
pub fn flush_bearing(&self) -> impl Iterator<Item = &'a ScrollLogLine> + '_ {
self.flushes.iter().copied().chain(self.finalize)
}
/// Intra-stream flush spacings (ms), in order.
///
/// Skips the stream's **first** flush-bearing record: the producer's
/// `ms_since_prev_flush` is global (its `last_flush_at` never resets at
/// stream boundaries — see `scroll_log.rs`), so the first value
/// measures from the previous stream's last flush/finalize and says
/// nothing about this stream's cadence. Every subsequent flush-bearing
/// record's spacing is intra-stream by construction.
pub fn intra_stream_flush_spacings_ms(&self) -> Vec<f64> {
self.flush_bearing()
.skip(1)
.filter_map(|record| record.ms_since_prev_flush)
.collect()
}
}
/// Group parsed records into per-gesture [`StreamGroup`]s.
///
/// Expects the shape the pager emits when the recorder exists for the whole
/// session (`KIGI_SCROLL_LOG` set at spawn): `stream_start` → `flush`* →
/// `finalize`, repeated, with at most one trailing unfinalized stream. A
/// direction flip emits `finalize` and the next `stream_start` at the same
/// `ts_ms`; that boundary is a plain group boundary here.
///
/// Malformed shapes (flush/finalize before any start, start while a stream
/// is open) are errors: with a spawn-time recorder they indicate producer
/// drift. Caveat: a `/debug log` *runtime-toggled* recorder can begin
/// mid-stream and legitimately open with an orphan flush/finalize — the
/// matrix never does that, so it is rejected rather than silently grouped.
pub fn group_streams(records: &[ScrollLogLine]) -> Result<Vec<StreamGroup<'_>>> {
let mut groups: Vec<StreamGroup<'_>> = Vec::new();
let mut open: Option<StreamGroup<'_>> = None;
for (idx, record) in records.iter().enumerate() {
let record_no = idx + 1;
match record.evt.as_str() {
EVT_STREAM_START => {
if open.is_some() {
bail!(
"record {record_no}: stream_start at ts_ms={} while the previous \
stream is still open (producer emits finalize before the next start)",
record.ts_ms
);
}
open = Some(StreamGroup {
start: record,
flushes: Vec::new(),
finalize: None,
});
}
EVT_FLUSH => match open.as_mut() {
Some(group) => group.flushes.push(record),
None => bail!(
"record {record_no}: flush at ts_ms={} with no open stream \
(missing stream_start)",
record.ts_ms
),
},
EVT_FINALIZE => match open.take() {
Some(mut group) => {
group.finalize = Some(record);
groups.push(group);
}
None => bail!(
"record {record_no}: finalize at ts_ms={} with no open stream \
(missing stream_start)",
record.ts_ms
),
},
other => bail!("record {record_no}: unknown evt {other:?}"),
}
}
// A trailing stream still in flight at capture end is legitimate.
groups.extend(open);
Ok(groups)
}
/// Poll interval for [`wait_for_finalize_count`]. Short enough that the
/// wait adds at most ~10ms latency past the write, long enough not to spin.
const FINALIZE_POLL_INTERVAL: Duration = Duration::from_millis(10);
/// Block until `path` contains at least `n` finalize records, or `timeout`
/// expires.
///
/// The producer force-flushes its `BufWriter` on every finalize record
/// (gesture boundary — the `tail -f` contract in `scroll_log.rs`), so
/// polling the file is race-free for finalize counting: once the flush
/// lands, the line is fully present. Counting uses a raw substring match
/// (`"evt":"finalize"` — serde_json's compact encoding) rather than a full
/// parse so a torn non-finalize tail mid-write can't fail the wait. A
/// not-yet-created file (the recorder opens lazily on the first record)
/// counts as zero.
pub fn wait_for_finalize_count(path: &Path, n: usize, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
let count = count_finalize_lines(path)?;
if count >= n {
return Ok(());
}
if Instant::now() >= deadline {
bail!(
"timed out after {timeout:?} waiting for {n} finalize record(s) in {}: found {count}",
path.display()
);
}
std::thread::sleep(FINALIZE_POLL_INTERVAL);
}
}
/// Count finalize records by raw substring; missing file counts as zero.
fn count_finalize_lines(path: &Path) -> Result<usize> {
match std::fs::read_to_string(path) {
Ok(raw) => Ok(raw.matches("\"evt\":\"finalize\"").count()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(err) => {
Err(err).with_context(|| format!("failed to read scroll log {}", path.display()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Fixture lines shaped exactly like the producer's serde output
// (compact JSON, snake_case evt/trigger, config echo flattened onto
// stream_start, skip-if-None optionals) — copied from the wire format
// pinned by `scroll_log_records_flood_flushes_and_capped_finalize_drop`
// in `kigi-tui/src/input/mouse/tests.rs`.
const START: &str = r#"{"ts_ms":0.0,"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":0.0,"cap":6,"mode":"trackpad","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":1.0,"viewport_height":40}"#;
const FLUSH_FIRST: &str = r#"{"ts_ms":16.0,"evt":"flush","trigger":"event","kind":"trackpad","events_total":9,"events_since_flush":9,"avg_interval_ms":2.0,"accel":1.0,"desired":9.4,"applied_total":6,"flushed":6,"backlog_after":3,"carry":0.4,"cap":6}"#;
const FLUSH_SECOND: &str = r#"{"ts_ms":32.0,"evt":"flush","trigger":"tick","kind":"trackpad","events_total":17,"events_since_flush":8,"avg_interval_ms":2.0,"accel":1.0,"desired":17.4,"applied_total":12,"flushed":6,"backlog_after":5,"carry":0.4,"cap":6,"ms_since_prev_flush":16.0}"#;
const FINALIZE: &str = r#"{"ts_ms":114.0,"evt":"finalize","trigger":"finalize","kind":"trackpad","events_total":50,"events_since_flush":33,"avg_interval_ms":2.0,"accel":1.0,"desired":50.0,"applied_total":18,"flushed":6,"backlog_after":26,"carry":0.0,"cap":6,"ms_since_prev_flush":82.0,"dropped":26}"#;
fn jsonl(lines: &[&str]) -> String {
let mut out = lines.join("\n");
out.push('\n'); // producer's writeln! always terminates lines
out
}
#[test]
fn parses_producer_shaped_fixture_lines() {
let records =
parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, FLUSH_SECOND, FINALIZE])).expect("parse");
assert_eq!(records.len(), 4);
let start = &records[0];
assert!(start.is_stream_start());
assert_eq!(start.trigger, "event");
assert_eq!(start.kind, "unknown");
assert_eq!(start.events_total, 0);
assert_eq!(start.cap, 6);
// Config echo rides stream_start only.
assert_eq!(start.mode.as_deref(), Some("trackpad"));
assert_eq!(start.ept, Some(3));
assert_eq!(start.wheel_lpt, Some(3));
assert_eq!(start.trackpad_lpt, Some(3));
assert_eq!(start.invert, Some(false));
assert_eq!(start.speed, Some(1.0));
assert_eq!(start.viewport_height, Some(40));
assert!(start.dropped.is_none());
assert!(start.ms_since_prev_flush.is_none());
let first_flush = &records[1];
assert!(first_flush.is_flush());
assert_eq!(first_flush.flushed, 6);
assert_eq!(first_flush.avg_interval_ms, Some(2.0));
// No flush-bearing record precedes it in this capture.
assert!(first_flush.ms_since_prev_flush.is_none());
assert!(first_flush.mode.is_none(), "flushes skip the config echo");
let finalize = &records[3];
assert!(finalize.is_finalize());
assert_eq!(finalize.trigger, "finalize");
assert_eq!(finalize.dropped, Some(26));
assert_eq!(finalize.backlog_after, 26);
assert_eq!(finalize.ms_since_prev_flush, Some(82.0));
assert_eq!(finalize.ts_ms, 114.0);
}
#[test]
fn unknown_fields_are_tolerated_for_additive_producer_changes() {
let with_future_field = FLUSH_SECOND.replace(
"\"ms_since_prev_flush\":16.0",
"\"ms_since_prev_flush\":16.0,\"future_field\":true",
);
let records = parse_jsonl_str(&jsonl(&[START, &with_future_field]))
.expect("additive fields must not break ingestion");
assert_eq!(records.len(), 2);
}
#[test]
fn missing_required_field_fails_with_line_number() {
// Simulate a producer-side rename: `carry` disappears from line 2.
let renamed = FLUSH_FIRST.replace("\"carry\":0.4,", "");
let err = parse_jsonl_str(&jsonl(&[START, &renamed])).expect_err("drift must fail");
let chain = format!("{err:#}");
assert!(chain.contains("line 2"), "no line number in: {chain}");
assert!(chain.contains("carry"), "no missing-field name in: {chain}");
}
#[test]
fn groups_flip_boundary_and_trailing_unfinalized_stream() {
// Direction flip: finalize and the next stream_start share ts_ms
// (both emitted from the same on_scroll_event call). The capture
// ends with stream 2 still in flight (no finalize).
let flip_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":40.0");
let flip_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":40.0");
let records = parse_jsonl_str(&jsonl(&[
START,
FLUSH_FIRST,
FLUSH_SECOND,
&flip_finalize,
&flip_start,
&FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":56.0"),
]))
.expect("parse");
let groups = group_streams(&records).expect("well-formed grouping");
assert_eq!(groups.len(), 2);
let first = &groups[0];
assert!(first.is_finalized());
assert_eq!(first.flushes.len(), 2);
assert_eq!(first.finalize.expect("finalized").ts_ms, 40.0);
let trailing = &groups[1];
assert!(!trailing.is_finalized(), "in-flight at capture end");
assert_eq!(trailing.start.ts_ms, 40.0, "flip start shares finalize ts");
assert_eq!(trailing.flushes.len(), 1);
}
#[test]
fn orphan_records_and_double_start_are_rejected() {
let records = parse_jsonl_str(&jsonl(&[FLUSH_FIRST])).expect("parse");
let err = group_streams(&records).expect_err("orphan flush");
assert!(format!("{err:#}").contains("no open stream"));
let records = parse_jsonl_str(&jsonl(&[FINALIZE])).expect("parse");
let err = group_streams(&records).expect_err("orphan finalize");
assert!(format!("{err:#}").contains("no open stream"));
let records = parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, START])).expect("parse");
let err = group_streams(&records).expect_err("start while open");
assert!(format!("{err:#}").contains("still open"));
}
#[test]
fn per_stream_spacing_skips_the_global_first_flush_record() {
// Stream 2's first flush carries ms_since_prev_flush measured from
// stream 1's finalize (producer's last_flush_at is global) — 500ms
// of inter-gesture idle that is NOT stream-2 cadence and must be
// skipped; the later flush (16ms) and finalize (82ms) are kept.
let s2_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":600.0");
let s2_flush_global = FLUSH_SECOND
.replace("\"ts_ms\":32.0", "\"ts_ms\":616.0")
.replace(
"\"ms_since_prev_flush\":16.0",
"\"ms_since_prev_flush\":500.0",
);
let s2_flush_intra = FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":632.0");
let s2_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":714.0");
let records = parse_jsonl_str(&jsonl(&[
START,
FLUSH_FIRST,
FINALIZE,
&s2_start,
&s2_flush_global,
&s2_flush_intra,
&s2_finalize,
]))
.expect("parse");
let groups = group_streams(&records).expect("grouping");
assert_eq!(groups.len(), 2);
assert_eq!(
groups[1].intra_stream_flush_spacings_ms(),
vec![16.0, 82.0],
"the 500ms cross-stream value must be skipped"
);
// Stream 1: first flush has no spacing at all (recorder start);
// only the finalize's intra-stream spacing remains.
assert_eq!(groups[0].intra_stream_flush_spacings_ms(), vec![82.0]);
}
#[test]
fn wait_for_finalize_count_write_then_check_phases() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("scroll-log.jsonl");
// Lazy-open recorder: the file does not exist yet — n=0 succeeds.
wait_for_finalize_count(&path, 0, Duration::ZERO).expect("n=0 on missing file");
// …but n=1 times out, reporting the current count.
let err = wait_for_finalize_count(&path, 1, Duration::from_millis(30))
.expect_err("missing file cannot satisfy n=1");
assert!(format!("{err:#}").contains("found 0"), "err: {err:#}");
let mut file = std::fs::File::create(&path).expect("create");
writeln!(file, "{START}").expect("write");
writeln!(file, "{FLUSH_FIRST}").expect("write");
writeln!(file, "{FINALIZE}").expect("write");
file.flush().expect("flush");
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("one finalize present");
// A torn tail mid-write (BufWriter fill boundary) must not fail the
// wait — counting is a raw substring match, not a parse.
write!(file, "{{\"ts_ms\":9").expect("torn tail");
file.flush().expect("flush");
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("torn tail tolerated");
let err = wait_for_finalize_count(&path, 2, Duration::from_millis(30))
.expect_err("only one finalize so far");
assert!(format!("{err:#}").contains("found 1"), "err: {err:#}");
// Completing the torn record into a second finalize satisfies n=2.
writeln!(file, ".0,\"evt\":\"finalize\"}}").expect("complete tail");
file.flush().expect("flush");
wait_for_finalize_count(&path, 2, Duration::from_secs(5)).expect("two finalizes");
}
#[test]
fn wait_for_finalize_count_observes_concurrent_appends() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("scroll-log.jsonl");
let writer_path = path.clone();
// Bounded writer delay, generous deadline: the wait must return as
// soon as the poll sees the flushed finalize, not at the deadline.
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
let mut file = std::fs::File::create(&writer_path).expect("create");
writeln!(file, "{START}").expect("write");
writeln!(file, "{FINALIZE}").expect("write");
file.flush().expect("flush");
});
wait_for_finalize_count(&path, 1, Duration::from_secs(10))
.expect("poll must observe the concurrent append");
writer.join().expect("writer thread");
}
}
@@ -0,0 +1,52 @@
//! Scroll validation matrix.
//!
//! The scroll matrix drives the pager binary in a PTY with
//! `KIGI_SCROLL_LOG` pointed at a tempfile, then validates the pager's
//! flight-recorder JSONL (producer:
//! `kigi-tui/src/input/scroll_log.rs`) against gesture invariants.
//!
//! Layers (per-cell flow, executed by [`runner::run_cell`]):
//! [`session`] spawns the primed pager → the cell's [`gestures`] table is
//! replayed as timed SGR reports → [`log`] parses and groups the recorder
//! JSONL (finalize-synchronized, no fixed sleeps) → the cell's
//! [`invariants`] judge the [`cells`] row's verdict, xfail-aware, and
//! [`report`] renders the verdicts (table, `report.json`, exit code).
//! Entry points: the curated CI tier (`tests/scroll_matrix_curated.rs`) and
//! the local full sweep (`src/bin/scroll_matrix.rs`).
//!
//! ## No pager dependency
//!
//! The harness reaches the pager **only** through the spawned binary
//! (`PAGER_BINARY` / `env::pager_binary`); this module re-declares the wire
//! schema instead of importing pager types. That duplication is deliberate:
//! [`log::ScrollLogLine`] keeps every always-emitted producer field
//! **required**, so a rename/removal on the pager side fails deserialization
//! loudly here (schema-drift tripwire), while unknown fields are tolerated
//! so additive producer changes don't break older matrix code. The
//! producer-side twin lives in `kigi-tui/src/input/mouse/tests.rs`
//! (wire-format fixture test asserting the same key set on raw JSON).
pub mod cells;
pub mod gestures;
pub mod invariants;
pub mod log;
pub mod report;
pub mod runner;
pub mod session;
pub use cells::{CELLS, ExpectedProfile, MatrixCell, Tier, curated};
pub use gestures::{GestureId, WheelStep, direction_counts};
pub use invariants::{InvariantId, InvariantResult, check_log_invariant};
pub use log::{
EVT_FINALIZE, EVT_FLUSH, EVT_STREAM_START, ScrollLogLine, StreamGroup, group_streams,
parse_jsonl, parse_jsonl_str, wait_for_finalize_count,
};
pub use report::{
CellReport, CellStatus, InvariantReport, InvariantStatus, exit_code, summary_table,
write_report_json,
};
pub use runner::run_cell;
pub use session::{
SessionKind, marker_line, marker_response, marker_screen_row, spawn_marker_session,
spawn_settled_marker_session, spawn_streaming_marker_session, topmost_visible_marker,
};
@@ -0,0 +1,283 @@
//! Cell verdicts: the `report.json` artifact, the stdout summary table, and
//! the exit-code policy shared by the curated CI tests and the
//! `scroll-matrix` sweep binary.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
/// Verdict of one invariant row within a cell run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InvariantStatus {
/// Held, and was expected to hold.
Pass,
/// Violated outside the cell's xfail set.
Fail,
/// Violated inside the xfail set (the declared known bug).
XFail,
/// Held despite being in the xfail set — the bug got fixed or the cell
/// rotted; either way the cell must be promoted out of xfail, so this
/// fails the run exactly like [`InvariantStatus::Fail`].
XPass,
}
/// Cell-level verdict: the worst of its invariant rows
/// (`Fail > XPass > XFail > Pass` — see `runner::classify`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CellStatus {
Pass,
Fail,
XFail,
XPass,
}
impl CellStatus {
/// Fixed-width table label.
pub fn as_str(self) -> &'static str {
match self {
CellStatus::Pass => "PASS",
CellStatus::Fail => "FAIL",
CellStatus::XFail => "XFAIL",
CellStatus::XPass => "XPASS",
}
}
}
/// One invariant row of a [`CellReport`].
#[derive(Clone, Debug, Serialize)]
pub struct InvariantReport {
/// Design vocabulary id (`I-ORD`, …).
pub id: String,
pub status: InvariantStatus,
/// Violation detail, or the promote-out-of-xfail note on XPass.
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
/// Verdict of one matrix cell run.
#[derive(Clone, Debug, Serialize)]
pub struct CellReport {
pub cell_id: String,
/// `curated` | `full`.
pub tier: String,
pub status: CellStatus,
/// One row per declared invariant; empty when the run aborted before
/// evaluation (see `note`).
pub invariants: Vec<InvariantReport>,
/// The cell's `KIGI_SCROLL_LOG` capture (kept for post-mortems).
pub log_path: String,
/// Streams grouped out of the capture (finalized + trailing in-flight).
pub streams: usize,
pub duration_ms: u64,
/// Phase note for runs that never reached invariant evaluation (setup
/// panic, finalize-wait timeout, the per-cell hard cap).
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Exit-code policy: nonzero iff any cell **Fail** or **XPass** — expected
/// failures (XFail) are green, but a fixed-or-rotted xfail cell must break
/// the run so it gets promoted instead of silently absorbed.
pub fn exit_code(reports: &[CellReport]) -> u8 {
let failed = reports
.iter()
.any(|r| matches!(r.status, CellStatus::Fail | CellStatus::XPass));
u8::from(failed)
}
/// Write `report.json` (pretty, array of [`CellReport`]) into `dir`,
/// creating it as needed; returns the file path.
pub fn write_report_json(reports: &[CellReport], dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("create artifacts dir {}", dir.display()))?;
let path = dir.join("report.json");
let json = serde_json::to_string_pretty(reports).context("serialize cell reports")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// Detail column source: the run's phase note, else the first non-Pass
/// invariant row's `id: detail`.
fn detail_for(report: &CellReport) -> String {
if let Some(note) = &report.note {
return note.clone();
}
report
.invariants
.iter()
.find(|row| row.status != InvariantStatus::Pass)
.map(|row| {
let detail = row.detail.as_deref().unwrap_or("");
format!("{}: {detail}", row.id)
})
.unwrap_or_else(|| "-".to_owned())
}
/// Truncation width for the table's detail column (full text lives in
/// `report.json`).
const DETAIL_WIDTH: usize = 72;
/// Render the aligned one-row-per-cell summary table. Plain ASCII, no
/// ANSI/TTY dependence — safe for CI logs and `| tee`.
pub fn summary_table(reports: &[CellReport]) -> String {
let mut rows: Vec<[String; 6]> = vec![[
"CELL".into(),
"TIER".into(),
"STATUS".into(),
"STREAMS".into(),
"TIME".into(),
"DETAIL".into(),
]];
for report in reports {
let mut detail = detail_for(report).replace(['\n', '\r'], " ");
if detail.len() > DETAIL_WIDTH {
let cut = (0..=DETAIL_WIDTH.saturating_sub(3))
.rev()
.find(|&i| detail.is_char_boundary(i))
.unwrap_or(0);
detail.truncate(cut);
detail.push_str("...");
}
rows.push([
report.cell_id.clone(),
report.tier.clone(),
report.status.as_str().to_owned(),
report.streams.to_string(),
format!("{}ms", report.duration_ms),
detail,
]);
}
let mut widths = [0usize; 6];
for row in &rows {
for (width, cell) in widths.iter_mut().zip(row) {
*width = (*width).max(cell.len());
}
}
let mut out = String::new();
for row in &rows {
let mut line = String::new();
for (width, cell) in widths.iter().zip(row) {
line.push_str(&format!("{cell:<width$} "));
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn report(cell_id: &str, status: CellStatus, rows: Vec<InvariantReport>) -> CellReport {
CellReport {
cell_id: cell_id.to_owned(),
tier: "curated".to_owned(),
status,
invariants: rows,
log_path: format!("/tmp/{cell_id}.jsonl"),
streams: 1,
duration_ms: 1234,
note: None,
}
}
fn row(id: &str, status: InvariantStatus, detail: Option<&str>) -> InvariantReport {
InvariantReport {
id: id.to_owned(),
status,
detail: detail.map(str::to_owned),
}
}
#[test]
fn exit_code_is_nonzero_iff_fail_or_xpass() {
let pass = report("a", CellStatus::Pass, vec![]);
let xfail = report("b", CellStatus::XFail, vec![]);
let fail = report("c", CellStatus::Fail, vec![]);
let xpass = report("d", CellStatus::XPass, vec![]);
assert_eq!(exit_code(&[]), 0);
assert_eq!(
exit_code(&[pass.clone(), xfail.clone()]),
0,
"XFail is green"
);
assert_eq!(exit_code(&[pass.clone(), fail]), 1);
// The fixed-bug tripwire: an xfail cell that PASSES must break the run.
assert_eq!(exit_code(&[pass, xfail, xpass]), 1, "XPass must be nonzero");
}
#[test]
fn report_json_shape_and_optional_fields() {
let full = report(
"cell_x",
CellStatus::XFail,
vec![row("I-NO-DROP", InvariantStatus::XFail, Some("dropped 74"))],
);
let value = serde_json::to_value(&full).expect("serialize");
assert_eq!(value["cell_id"], "cell_x");
assert_eq!(value["tier"], "curated");
assert_eq!(value["status"], "x_fail", "snake_case enum labels");
assert_eq!(value["streams"], 1);
assert_eq!(value["duration_ms"], 1234);
assert_eq!(value["invariants"][0]["id"], "I-NO-DROP");
assert_eq!(value["invariants"][0]["status"], "x_fail");
assert_eq!(value["invariants"][0]["detail"], "dropped 74");
assert!(
value.get("note").is_none(),
"None note must not serialize: {value}"
);
let pass_row = serde_json::to_value(row("I-ORD", InvariantStatus::Pass, None)).unwrap();
assert!(pass_row.get("detail").is_none(), "None detail skipped");
}
#[test]
fn write_report_json_creates_dir_and_file() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("artifacts");
let path = write_report_json(&[report("a", CellStatus::Pass, vec![])], &nested)
.expect("write report");
assert_eq!(path, nested.join("report.json"));
let raw = std::fs::read_to_string(&path).expect("read back");
let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
assert_eq!(parsed[0]["cell_id"], "a");
}
#[test]
fn summary_table_aligns_columns_and_stays_single_line_per_cell() {
let reports = vec![
report("short", CellStatus::Pass, vec![]),
report(
"a_much_longer_cell_identifier",
CellStatus::Fail,
vec![row(
"I-CAP",
InvariantStatus::Fail,
Some(&"flushed 40 exceeds cap 25\nwith a newline".repeat(4)),
)],
),
];
let table = summary_table(&reports);
let lines: Vec<&str> = table.lines().collect();
assert_eq!(lines.len(), 3, "header + one row per cell:\n{table}");
// Every row's STATUS column starts where the header's does.
let status_col = lines[0].find("STATUS").expect("header STATUS");
assert_eq!(&lines[1][status_col..status_col + 4], "PASS");
assert_eq!(&lines[2][status_col..status_col + 4], "FAIL");
// Failure detail is carried (truncated, newlines flattened).
assert!(lines[2].contains("I-CAP: flushed 40"), "table:\n{table}");
assert!(lines[2].contains("..."), "long detail truncated:\n{table}");
assert!(
!table.contains('\x1b'),
"table must be plain ASCII (non-TTY-safe)"
);
}
}
@@ -0,0 +1,465 @@
//! The per-cell executor: spawn the primed pager, replay the gesture,
//! synchronize on the recorder, judge the invariants, classify the verdict.
//!
//! ## Blocking model
//!
//! A cell's body ([`run_cell_inner`]) is host-paced end to end — PTY drains,
//! `thread::sleep` gesture gaps, finalize polling — so [`run_cell`] runs it
//! on `spawn_blocking` (driving the async session spawns via
//! `Handle::block_on`, which requires a **multi-thread** runtime) and applies
//! the per-cell hard cap with `tokio::time::timeout` on the join handle.
//! That also converts setup panics (the session preambles assert with the
//! screen contents) into a `Fail` report instead of killing the whole sweep.
//! A capped cell's blocking task cannot be aborted mid-syscall: it is left
//! to unwind on its own bounded waits (its `Drop`s kill the pager child and
//! mock server), while the sweep moves on.
//!
//! ## Harness-side invariants
//!
//! [`InvariantId::Screen`] and [`InvariantId::Quiet`] are checked here (the
//! log alone can't see the viewport or repaints — `invariants.rs` panics on
//! them by contract): I-QUIET counts frames in a post-finalize watermark
//! window, I-SCREEN replays the per-stream `applied_total`s through a
//! bottom-clamped travel simulation and compares the topmost visible marker
//! against the session baseline.
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use super::cells::{MatrixCell, Tier};
use super::invariants::{InvariantId, InvariantResult, check_log_invariant};
use super::log::{StreamGroup, group_streams, parse_jsonl, wait_for_finalize_count};
use super::report::{CellReport, CellStatus, InvariantReport, InvariantStatus};
use super::session::{
SessionKind, WHEEL_COL, WHEEL_ROW, spawn_marker_session, topmost_visible_marker,
};
/// Transcript height for every cell: comfortably taller than the 50-row PTY
/// (the preamble's scrollable-baseline guard) with enough headroom that the
/// small gestures judged by I-SCREEN never clamp at the transcript top.
/// Deliberate travel clamping (floods can deliver hundreds of rows) is fine:
/// no log-side invariant reads the viewport.
const MARKER_COUNT: usize = 400;
/// Recorder-synchronization budget: every gesture table spans < 1.5s and a
/// stream finalizes 80ms after its last event, so 5s only trips on a real
/// wedge (or a stall so long the cell is unusable anyway).
const FINALIZE_TIMEOUT: Duration = Duration::from_secs(5);
/// Post-finalize settle: consume the gesture-era PTY backlog so the quiet
/// window below counts only NEW frames (the harness parses chunks lazily in
/// `update`, not at arrival).
const PIPELINE_DRAIN: Duration = Duration::from_millis(300);
/// I-QUIET observation window after the drain + watermark reset.
const QUIET_WINDOW: Duration = Duration::from_millis(500);
/// I-QUIET allowance: a straggler cadence/finalize paint mid-pipeline may
/// land after the watermark; repaint CHURN (the A2 symptom) paints dozens.
const QUIET_MAX_FRAMES: u64 = 2;
/// Streaming-session teardown budget: the paced tail is ~7s at spawn time,
/// so the released turn completes well inside this.
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(30);
/// Per-cell hard cap (spawn → verdict). The slowest legitimate cell — the
/// streaming preamble plus its post-gesture tail drain — finishes in ~20s.
const CELL_HARD_CAP: Duration = Duration::from_secs(60);
/// `tier` label for [`CellReport`].
fn tier_label(tier: Tier) -> &'static str {
match tier {
Tier::Curated => "curated",
Tier::Full => "full",
}
}
/// One SGR (DECSET 1006) wheel press report at the shared in-scrollback
/// position — 0-based [`WHEEL_ROW`]/[`WHEEL_COL`] encode 1-based on the wire
/// (same bytes as the pager e2e `sgr_mouse` helper).
fn sgr_wheel_report(button: u16) -> String {
format!("\x1b[<{button};{};{}M", WHEEL_COL + 1, WHEEL_ROW + 1)
}
/// Run one matrix cell against `binary`, capturing the recorder JSONL to
/// `artifacts_dir/<cell_id>.jsonl` (kept for post-mortems). Never panics and
/// never hangs past [`CELL_HARD_CAP`]; every abnormality becomes a `Fail`
/// report with a phase note. Requires a multi-thread tokio runtime (see the
/// module docs).
pub async fn run_cell(cell: &MatrixCell, binary: &Path, artifacts_dir: &Path) -> CellReport {
let started = Instant::now();
let cell = *cell;
let log_path = artifacts_dir.join(format!("{}.jsonl", cell.id));
let outcome = match std::fs::create_dir_all(artifacts_dir)
.with_context(|| format!("create artifacts dir {}", artifacts_dir.display()))
{
Err(err) => Err(format!("artifacts setup: {err:#}")),
Ok(()) => {
let handle = tokio::runtime::Handle::current();
let (binary, inner_log) = (binary.to_path_buf(), log_path.clone());
let task = tokio::task::spawn_blocking(move || {
handle.block_on(run_cell_inner(cell, &binary, &inner_log))
});
match tokio::time::timeout(CELL_HARD_CAP, task).await {
Err(_) => Err(format!(
"hard cap: cell still running after {CELL_HARD_CAP:?} (phase unknown; \
the cell task is left to unwind on its own bounded waits)"
)),
Ok(Err(join_err)) => Err(format!("panic: {}", panic_message(join_err))),
Ok(Ok(Err(err))) => Err(format!("{err:#}")),
Ok(Ok(Ok(run))) => Ok(run),
}
}
};
let (status, invariants, streams, note) = match outcome {
Ok(run) => {
let (status, invariants) = classify(&run.outcomes, cell.xfail);
(status, invariants, run.streams, None)
}
Err(note) => (CellStatus::Fail, Vec::new(), 0, Some(note)),
};
CellReport {
cell_id: cell.id.to_owned(),
tier: tier_label(cell.tier).to_owned(),
status,
invariants,
log_path: log_path.display().to_string(),
streams,
duration_ms: started.elapsed().as_millis() as u64,
note,
}
}
fn panic_message(err: tokio::task::JoinError) -> String {
match err.try_into_panic() {
Ok(payload) => payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_owned()),
Err(err) => format!("cell task failed without panicking: {err}"),
}
}
/// Everything [`run_cell`] needs from a completed (non-aborted) cell body.
struct CellRun {
outcomes: Vec<(InvariantId, InvariantResult)>,
streams: usize,
}
async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Result<CellRun> {
// Stale-capture guard: the recorder opens its file lazily on the first
// record, so a leftover capture from a previous run would satisfy the
// finalize wait with the OLD gesture's records.
if log_path.exists() {
std::fs::remove_file(log_path)
.with_context(|| format!("setup: remove stale capture {}", log_path.display()))?;
}
let log_value = log_path
.to_str()
.context("setup: artifacts path is not UTF-8")?;
let mut env: Vec<(&str, &str)> = cell.env.to_vec();
env.push(("KIGI_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) =
spawn_marker_session(binary, cell.session, MARKER_COUNT, &env).await;
// Replay the gesture table: sleep each step's pre-delay (host-side lower
// bound — jitter only stretches gaps), then emit its report. Port of the
// pager e2e `send_wheel_sequence` loop onto `WheelStep`.
for step in cell.gesture.steps(cell.expected.ept) {
if step.pre_delay_ms > 0 {
std::thread::sleep(Duration::from_millis(step.pre_delay_ms));
}
harness
.inject_keys(sgr_wheel_report(step.button).as_bytes())
.context("gesture: inject wheel report")?;
}
wait_for_finalize_count(log_path, cell.gesture.expected_streams(), FINALIZE_TIMEOUT)
.context("gesture: finalize wait")?;
// Drain the gesture-era backlog, then watermark → the quiet window
// counts only post-finalize frames; the marker read afterwards sees the
// fully painted final viewport (I-SCREEN's input).
harness.update(PIPELINE_DRAIN);
harness.reset_timing();
harness.update(QUIET_WINDOW);
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();
let deadline = Instant::now() + COMPLETION_TIMEOUT;
while harness.contains_text("Responding") {
if Instant::now() >= deadline {
bail!("teardown: turn never completed after the gate release");
}
harness.update(Duration::from_millis(200));
}
}
harness.quit().context("teardown: quit pager")?;
drop(content);
// The pager exited (recorder flushed + closed), so the capture is
// complete and torn-tail-free by construction.
let records = parse_jsonl(log_path).context("verdict: parse capture")?;
let groups = group_streams(&records).context("verdict: group streams")?;
let outcomes = cell
.invariants
.iter()
.map(|&id| {
let result = if id.is_log_side() {
check_log_invariant(id, &cell.expected, &groups)
} else {
match id {
InvariantId::Screen => {
check_screen(cell.session, baseline, marker_after, &groups)
}
InvariantId::Quiet => check_quiet(quiet_frames),
_ => unreachable!("is_log_side covers every other id"),
}
};
(id, result)
})
.collect();
Ok(CellRun {
outcomes,
streams: groups.len(),
})
}
/// I-QUIET: no repaint churn after the last finalize — at most
/// [`QUIET_MAX_FRAMES`] frames land in the watermark window.
fn check_quiet(quiet_frames: u64) -> InvariantResult {
if quiet_frames > QUIET_MAX_FRAMES {
return InvariantResult::Violated {
detail: format!(
"{quiet_frames} frames painted in the {QUIET_WINDOW:?} post-finalize window \
(> {QUIET_MAX_FRAMES}) — repaint churn after the gesture ended"
),
};
}
InvariantResult::Pass
}
/// Net signed lines a stream delivered: its last flush-bearing record's
/// cumulative `applied_total` (finalize when present, else the last flush of
/// a trailing in-flight stream).
fn stream_applied(group: &StreamGroup<'_>) -> i64 {
group
.flush_bearing()
.last()
.map_or(0, |record| record.applied_total)
}
/// Replay per-stream deliveries through the viewport's clamps: `0` is the
/// bottom pin (down-deliveries there don't move — G7's point), and the
/// return is clamped to `-baseline` because travel above marker 0 pins the
/// topmost visible marker at 0.
fn simulate_clamped_travel(baseline: usize, applied: impl IntoIterator<Item = i64>) -> i64 {
let mut pos: i64 = 0;
for delta in applied {
pos = (pos + delta).min(0);
}
pos.max(-(baseline as i64))
}
/// I-SCREEN: the viewport visibly moved/clamped exactly as the recorder
/// says it should have — topmost-marker delta equals the bottom-clamped
/// replay of the per-stream `applied_total`s (up is negative, matching the
/// producer's `ScrollDirection` sign and the marker index direction).
fn check_screen(
session: SessionKind,
baseline: usize,
marker_after: Option<usize>,
groups: &[StreamGroup<'_>],
) -> InvariantResult {
if session == SessionKind::Streaming {
// The live bottom keeps growing mid-stream, so "marker delta ==
// applied" has no stable frame of reference — a cell-table bug.
return InvariantResult::Violated {
detail: "I-SCREEN attached to a streaming session (no stable baseline)".to_owned(),
};
}
let Some(after) = marker_after else {
return InvariantResult::Violated {
detail: "no marker visible after the gesture".to_owned(),
};
};
let expected =
baseline as i64 + simulate_clamped_travel(baseline, groups.iter().map(stream_applied));
if after as i64 != expected {
return InvariantResult::Violated {
detail: format!(
"topmost marker {baseline} -> {after} after the gesture, but the clamped \
replay of the streams' applied totals lands at {expected}"
),
};
}
InvariantResult::Pass
}
/// XPASS row detail: the actionable half of the xfail contract.
const XPASS_DETAIL: &str = "expected to violate (xfail) but PASSED — the pinned bug got fixed \
or the cell rotted; promote the invariant out of the xfail set";
/// Classify evaluated invariants into per-row statuses and the cell verdict.
/// Precedence: any `Fail` (non-xfail violation) fails the cell; else any
/// `XPass` fails it (fixed/rotted xfail must be promoted, not absorbed);
/// else any `XFail` marks the expected failure; else `Pass`.
fn classify(
outcomes: &[(InvariantId, InvariantResult)],
xfail: &[InvariantId],
) -> (CellStatus, Vec<InvariantReport>) {
let rows: Vec<InvariantReport> = outcomes
.iter()
.map(|(id, result)| {
let expected_to_fail = xfail.contains(id);
let (status, detail) = match (result, expected_to_fail) {
(InvariantResult::Pass, false) => (InvariantStatus::Pass, None),
(InvariantResult::Pass, true) => {
(InvariantStatus::XPass, Some(XPASS_DETAIL.to_owned()))
}
(InvariantResult::Violated { detail }, false) => {
(InvariantStatus::Fail, Some(detail.clone()))
}
(InvariantResult::Violated { detail }, true) => {
(InvariantStatus::XFail, Some(detail.clone()))
}
};
InvariantReport {
id: id.as_str().to_owned(),
status,
detail,
}
})
.collect();
let has = |status: InvariantStatus| rows.iter().any(|row| row.status == status);
let status = if has(InvariantStatus::Fail) {
CellStatus::Fail
} else if has(InvariantStatus::XPass) {
CellStatus::XPass
} else if has(InvariantStatus::XFail) {
CellStatus::XFail
} else {
CellStatus::Pass
};
(status, rows)
}
#[cfg(test)]
mod tests {
use super::*;
fn violated(detail: &str) -> InvariantResult {
InvariantResult::Violated {
detail: detail.to_owned(),
}
}
/// The wire bytes of the shared wheel position, pinned to the
/// `40;12` encoding documented on `WHEEL_ROW`/`WHEEL_COL`.
#[test]
fn sgr_report_encodes_one_based_wire_coords() {
assert_eq!(sgr_wheel_report(64), "\x1b[<64;40;12M");
assert_eq!(sgr_wheel_report(65), "\x1b[<65;40;12M");
}
#[test]
fn clamped_travel_models_bottom_pin_and_top_clamp() {
// G7's shape: down-deliveries at the pin don't move, the up tail does.
assert_eq!(simulate_clamped_travel(300, [10, -7]), -7);
// Up then partially back down: net movement, no clamp involved.
assert_eq!(simulate_clamped_travel(300, [-5, 3]), -2);
// Pure down at the pin stays put.
assert_eq!(simulate_clamped_travel(300, [10]), 0);
// Down past the pin then up: the overshoot must not bank as credit.
assert_eq!(simulate_clamped_travel(300, [25, -4]), -4);
// Travel beyond the transcript top pins the topmost marker at 0.
assert_eq!(simulate_clamped_travel(30, [-500]), -30);
}
#[test]
fn quiet_allows_the_straggler_allowance_only() {
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"))
);
}
#[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"))
);
let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]);
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), &[]);
assert!(matches!(moved, InvariantResult::Violated { .. }));
}
#[test]
fn classify_precedence_fail_over_xpass_over_xfail_over_pass() {
use InvariantId::{Cap, NoDrop, Ord, SmoothCoast};
// All pass, nothing xfailed → Pass.
let (status, rows) = classify(&[(Ord, InvariantResult::Pass)], &[]);
assert_eq!(status, CellStatus::Pass);
assert_eq!(rows[0].status, InvariantStatus::Pass);
assert_eq!(rows[0].id, "I-ORD");
// The declared bug violates, everything else passes → XFail.
let jerk = [
(Ord, InvariantResult::Pass),
(SmoothCoast, violated("coast")),
(NoDrop, violated("dropped 74")),
];
let (status, rows) = classify(&jerk, &[SmoothCoast, NoDrop]);
assert_eq!(status, CellStatus::XFail);
assert_eq!(rows[1].status, InvariantStatus::XFail);
assert_eq!(rows[1].detail.as_deref(), Some("coast"));
// One xfail row passing flips the cell to XPass (fixed-bug tripwire)…
let half_fixed = [
(SmoothCoast, InvariantResult::Pass),
(NoDrop, violated("dropped 74")),
];
let (status, rows) = classify(&half_fixed, &[SmoothCoast, NoDrop]);
assert_eq!(status, CellStatus::XPass);
assert!(rows[0].detail.as_deref().unwrap().contains("promote"));
// …but any non-xfail violation dominates everything.
let broken = [
(Cap, violated("flushed 40 exceeds cap 25")),
(SmoothCoast, InvariantResult::Pass),
];
let (status, _) = classify(&broken, &[SmoothCoast]);
assert_eq!(status, CellStatus::Fail);
}
#[test]
fn tier_labels_match_the_report_vocabulary() {
assert_eq!(tier_label(Tier::Curated), "curated");
assert_eq!(tier_label(Tier::Full), "full");
}
}
@@ -0,0 +1,318 @@
//! Marker-session preambles: spawn the pager into the primed state a matrix
//! cell's gesture assumes, ported from the pager's `tests/pty_e2e/scroll.rs`
//! (`spawn_bottom_pinned_marker_scrollback[_with_env]` /
//! `spawn_streaming_marker_turn`) onto this crate's public API so the A13
//! runner — which lives here, not in the pager's test tree — can reuse the
//! proven construction.
//!
//! ## Controller-drop footgun (kept from the originals)
//!
//! Destructure the returned controller into a LIVE binding (`content` /
//! `_content`), never `_` — a `_` binding drops it immediately, killing the
//! mock server mid-session and surfacing as a confusing 60s stream timeout
//! instead of an obvious failure.
//!
//! ## Gate-release ownership (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).
use std::path::Path;
use std::time::Duration;
use crate::PtyHarness;
use crate::content::ContentController;
/// Transcript state a cell's gesture starts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionKind {
/// Response fully streamed, viewport bottom-pinned, scrollback focused.
Settled,
/// Same preamble as [`SessionKind::Settled`] — the settle leaves the
/// viewport pinned to the bottom. A separate kind because the cell's
/// point is the pin itself (down-scroll must clamp: G7 / I-SCREEN),
/// not just "there is history above".
BottomPinned,
/// Turn still streaming (paced deltas + held completion gate) when the
/// gesture runs — mid-stream by construction (G8).
Streaming,
}
/// PTY geometry shared by every matrix session (the pager e2e default:
/// large enough that the welcome screen never wraps).
pub const SESSION_ROWS: u16 = 50;
pub const SESSION_COLS: u16 = 120;
/// Wheel-report position, 0-based (row, col): inside the scrollback pane at
/// the [`SESSION_ROWS`]×[`SESSION_COLS`] PTY (wire encodes as SGR `40;12`).
pub const WHEEL_ROW: u16 = 11;
pub const WHEEL_COL: u16 = 39;
/// Welcome-screen sentinel: the menu label `Quit` (case-sensitive, so the
/// lowercase authenticating hint can't match early).
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
/// Prompt submitted to the agent — short enough never to wrap.
const PROMPT: &str = "go";
/// First line inside the marker code block (the mock response sentinel).
const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
/// Prefix shared by [`marker_line`] and the screen parses.
const MARKER_PREFIX: &str = "MARKER-";
/// End marker of a streaming session's tail: the final streamed word, kept
/// off-screen while the tail is in flight (witness for "still streaming").
pub const STREAM_END_SENTINEL: &str = "STREAMDONE";
/// Unique numbered marker line (`MARKER-0042`), zero-padded so no marker is
/// a substring of another within a [`marker_response`] transcript.
pub fn marker_line(n: usize) -> String {
format!("{MARKER_PREFIX}{n:04}")
}
/// `count` numbered marker lines inside a fenced code block — markdown
/// renders exactly one row per marker with no soft-wrap reflow, so marker
/// index deltas equal row deltas.
pub fn marker_response(count: usize) -> String {
let mut s = String::with_capacity(count * 16 + 64);
s.push_str("```\n");
s.push_str(MOCK_RESPONSE_SENTINEL);
s.push('\n');
for i in 0..count {
s.push_str(&marker_line(i));
s.push('\n');
}
s.push_str("```\n");
s
}
/// Current screen row (0-based) of `marker`, or `None` when off-screen.
pub fn marker_screen_row(harness: &PtyHarness, marker: &str) -> Option<u16> {
harness
.screen_contents()
.lines()
.position(|line| line.contains(marker))
.map(|row| row as u16)
}
/// Index of the topmost marker on screen (`None` when none visible;
/// malformed hits skipped). Scrolling UP strictly decreases it by the rows
/// scrolled — the "viewport moved by K rows" primitive (I-SCREEN's input).
pub fn topmost_visible_marker(harness: &PtyHarness) -> Option<usize> {
topmost_marker_in(&harness.screen_contents())
}
fn topmost_marker_in(screen: &str) -> Option<usize> {
screen.lines().find_map(|line| {
let digits_at = line.find(MARKER_PREFIX)? + MARKER_PREFIX.len();
line.get(digits_at..digits_at + 4)?.parse().ok()
})
}
/// Streaming-session defaults: the marker block rides the FIRST delta; a
/// 240-word tail at 30ms per SSE event then streams for ≥7s — wider than
/// any gesture table (G9b, the longest, spans ~0.4s) plus its finalize
/// wait, so the gesture provably overlaps the stream. Values proven by the
/// pager's `wheel_*_mid_stream` e2e tests.
pub const STREAMING_TAIL_WORDS: usize = 240;
pub const STREAMING_CHUNK_DELAY: Duration = Duration::from_millis(30);
/// Spawn `binary` (the pager under test — the matrix runner plumbs its
/// `--binary` override here; tests pass `env::pager_binary()`) for `kind`
/// over a `marker_count`-marker transcript with `extra_env` appended to the
/// mock's pager env (terminal-class markers, `KIGI_SCROLL_*`,
/// `KIGI_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.
pub async fn spawn_marker_session(
binary: &Path,
kind: SessionKind,
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
match kind {
SessionKind::Settled | SessionKind::BottomPinned => {
spawn_settled_marker_session(binary, marker_count, extra_env).await
}
SessionKind::Streaming => {
spawn_streaming_marker_session(
binary,
marker_count,
STREAMING_TAIL_WORDS,
STREAMING_CHUNK_DELAY,
extra_env,
)
.await
}
}
}
/// Shared spawn: mock content up, pager in a [`SESSION_ROWS`]×
/// [`SESSION_COLS`] PTY with content env + `extra_env`, welcome waited.
fn spawn_pager(
binary: &Path,
content: &ContentController,
extra_env: &[(&str, &str)],
) -> PtyHarness {
let content_env = content.env_for_pager();
let mut env: Vec<(&str, &str)> = content_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env.extend_from_slice(extra_env);
let mut harness = PtyHarness::new(binary, SESSION_ROWS, SESSION_COLS, &[], &env)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
}
/// Panic (with the screen) unless the transcript overflows the viewport:
/// marker 0 off-screen-top and some later marker visible — otherwise the
/// gesture has nothing to scroll into view. Returns the movement baseline.
fn assert_scrollable_baseline(harness: &PtyHarness, context: &str) -> usize {
assert!(
marker_screen_row(harness, &marker_line(0)).is_none(),
"setup: {} already visible → transcript not taller than the screen ({context})\nscreen:\n{}",
marker_line(0),
harness.screen_contents()
);
topmost_visible_marker(harness).unwrap_or_else(|| {
panic!(
"setup: no marker visible ({context})\nscreen:\n{}",
harness.screen_contents()
)
})
}
/// [`SessionKind::Settled`]/[`SessionKind::BottomPinned`] preamble: stream
/// the whole marker transcript, land bottom-pinned in follow mode, focus
/// the scrollback via Tab (Esc would silently arm the rewind picker, whose
/// ~800ms expiry redraw pollutes frame captures; wheel reports are
/// position-routed regardless of focus), quiesce, reset the frame-timing
/// watermark — counted frames then come only from the caller's gesture.
pub async fn spawn_settled_marker_session(
binary: &Path,
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
let content = ContentController::start().await.expect("start content");
content.set_response(marker_response(marker_count));
let mut harness = spawn_pager(binary, &content, extra_env);
// The LAST marker is the last thing streamed → the whole transcript is in.
harness
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(60))
.expect("response finished streaming");
harness.update(Duration::from_millis(500));
let baseline = assert_scrollable_baseline(&harness, "settled");
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(500));
// Footer wait is best-effort (skin-dependent), as in the original.
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(5));
harness.update(Duration::from_millis(300));
harness.reset_timing();
(harness, content, baseline)
}
/// [`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).
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) {
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 mut harness = spawn_pager(binary, &content, extra_env);
// The last marker rides the first delta, so this waits only for the
// stream to START; the tail is still in flight afterwards.
harness
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(30))
.expect("marker block streamed");
harness.update(Duration::from_millis(300));
assert!(
!harness.contains_text(STREAM_END_SENTINEL),
"setup: {STREAM_END_SENTINEL} already on screen — the paced tail finished \
before the gesture could overlap the stream\nscreen:\n{}",
harness.screen_contents()
);
let baseline = assert_scrollable_baseline(&harness, "mid-stream");
(harness, content, baseline)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_transcript_shape() {
assert_eq!(marker_line(42), "MARKER-0042");
let response = marker_response(3);
assert!(response.starts_with("```\nMOCKRESPONSE\n"));
assert!(response.ends_with("MARKER-0002\n```\n"));
assert_eq!(response.matches(MARKER_PREFIX).count(), 3);
}
#[test]
fn topmost_marker_parses_top_down_and_skips_malformed_hits() {
let screen = "noise\nMARKER-12 truncated\n x MARKER-0007 y\nMARKER-0003\n";
assert_eq!(topmost_marker_in(screen), Some(7));
assert_eq!(topmost_marker_in("no markers here"), None);
}
/// The streaming defaults must dwarf the longest gesture: every step
/// table finishes (delays summed) well inside the paced tail's window,
/// or the "still streaming" guard could race the gesture.
#[test]
fn streaming_window_covers_every_gesture_table() {
use super::super::gestures::{GestureId, STREAM_GAP_MS};
let tail_ms = STREAMING_TAIL_WORDS as u64 * STREAMING_CHUNK_DELAY.as_millis() as u64;
for gesture in GestureId::ALL {
for ept in [1u16, 3] {
let span: u64 = gesture.steps(ept).iter().map(|s| s.pre_delay_ms).sum();
// Gesture + every stream's finalize gap, ×4 headroom.
let with_finalize = (span + gesture.expected_streams() as u64 * STREAM_GAP_MS) * 4;
assert!(
with_finalize < tail_ms,
"{gesture:?} (ept={ept}) span {with_finalize}ms crowds the \
{tail_ms}ms streaming window"
);
}
}
}
}
@@ -0,0 +1,128 @@
//! Layer 2b: Frame timing via VTE parser.
//!
//! Detects frame boundaries from synchronized-update markers
//! (`CSI ? 2026 h/l`) emitted by crossterm's `BeginSynchronizedUpdate` /
//! `EndSynchronizedUpdate`, and records wall-clock timing for each frame.
use std::time::{Duration, Instant};
// Use `vte` re-exported from `alacritty_terminal` (via ptyctl) instead of
// a separate direct dependency.
use alacritty_terminal::vte;
/// Timing data for a single rendered frame.
#[derive(Debug, Clone)]
pub struct FrameTiming {
/// Wall-clock duration from BeginSynchronizedUpdate to EndSynchronizedUpdate.
pub duration: Duration,
/// Number of printable characters emitted within this frame.
pub chars: usize,
}
/// Parses raw PTY output for synchronized-update frame boundaries and
/// records per-frame timing data.
///
/// Composes a `vte::Parser` with an internal `FrameTimingHandler` that
/// implements `vte::Perform`.
pub struct FrameTimingParser {
vte_parser: vte::Parser,
handler: FrameTimingHandler,
}
impl FrameTimingParser {
pub fn new() -> Self {
Self {
vte_parser: vte::Parser::new(),
handler: FrameTimingHandler::new(),
}
}
/// Feed raw PTY output bytes, parsing for frame boundaries.
pub fn feed(&mut self, bytes: &[u8]) {
self.vte_parser.advance(&mut self.handler, bytes);
}
/// Return all recorded frame timings.
pub fn timings(&self) -> &[FrameTiming] {
&self.handler.timings
}
/// Return the total number of completed frames.
pub fn frame_count(&self) -> u64 {
self.handler.timings.len() as u64
}
/// Reset all recorded timing data.
pub fn reset(&mut self) {
self.handler.timings.clear();
self.handler.frame_start = None;
self.handler.current_frame_chars = 0;
}
}
impl Default for FrameTimingParser {
fn default() -> Self {
Self::new()
}
}
/// Internal VTE `Perform` implementation that detects frame boundaries.
struct FrameTimingHandler {
frame_start: Option<Instant>,
timings: Vec<FrameTiming>,
current_frame_chars: usize,
}
impl FrameTimingHandler {
fn new() -> Self {
Self {
frame_start: None,
timings: Vec::new(),
current_frame_chars: 0,
}
}
}
impl vte::Perform for FrameTimingHandler {
fn csi_dispatch(
&mut self,
params: &vte::Params,
intermediates: &[u8],
_ignore: bool,
action: char,
) {
// Only interested in CSI ? <param> h/l (private mode set/reset).
if intermediates != b"?" {
return;
}
let param_val: u16 = params
.iter()
.next()
.and_then(|p| p.first().copied())
.unwrap_or(0);
if param_val == 2026 {
match action {
'h' => {
// BeginSynchronizedUpdate: frame starts.
self.frame_start = Some(Instant::now());
self.current_frame_chars = 0;
}
'l' => {
// EndSynchronizedUpdate: frame ends.
if let Some(start) = self.frame_start.take() {
self.timings.push(FrameTiming {
duration: start.elapsed(),
chars: self.current_frame_chars,
});
}
}
_ => {}
}
}
}
fn print(&mut self, _c: char) {
self.current_frame_chars += 1;
}
}