Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -3,7 +3,7 @@
|
|||
//! Individual test modules import via `use super::common::*`.
|
||||
|
||||
pub(crate) use serde_json::json;
|
||||
pub(crate) use std::path::Path;
|
||||
pub(crate) use std::path::{Path, PathBuf};
|
||||
pub(crate) use std::time::{Duration, Instant};
|
||||
pub(crate) use xai_grok_pager_pty_harness::{
|
||||
AgentTurnExpectation, ContentController, EnvOp, MockModel, PtyExitPoll, PtyHarness,
|
||||
|
|
@ -917,6 +917,63 @@ pub(crate) const MINIMAL_IDLE_SENTINEL: &str = "minimal · /help";
|
|||
pub(crate) const MINIMAL_SWITCH_BACK_IDLE_SENTINEL: &str =
|
||||
"minimal · /fullscreen to go back · /help";
|
||||
|
||||
/// Header of minimal's parked plan-approval controls strip.
|
||||
pub(crate) const PLAN_PARKED_SENTINEL: &str = "Plan ready for review";
|
||||
|
||||
/// A plan body the `exit_plan_mode` tool will read off disk. Every step carries
|
||||
/// a unique `{tag}{NNN}` sentinel, because a truncated plan still contains its
|
||||
/// head and would pass a plain substring check.
|
||||
pub(crate) fn plan_body(tag: &str, lines: usize) -> String {
|
||||
let mut s = format!("# {tag} Plan\n\n");
|
||||
for i in 0..lines {
|
||||
s.push_str(&format!("- {tag}{i:03} step of the plan\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Steps of a [`plan_body`] missing from everything the user could reach by
|
||||
/// scrolling: native scrollback plus the visible screen.
|
||||
pub(crate) fn plan_lines_missing(harness: &mut PtyHarness, tag: &str, lines: usize) -> Vec<usize> {
|
||||
let full = harness.full_text();
|
||||
(0..lines)
|
||||
.filter(|i| !full.contains(&format!("{tag}{i:03}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Steps of a [`plan_body`] that appear more than once — the print-once guard.
|
||||
pub(crate) fn plan_lines_duplicated(
|
||||
harness: &mut PtyHarness,
|
||||
tag: &str,
|
||||
lines: usize,
|
||||
) -> Vec<usize> {
|
||||
let full = harness.full_text();
|
||||
(0..lines)
|
||||
.filter(|i| full.matches(&format!("{tag}{i:03}")).count() > 1)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Locate `<grok_home>/sessions/<encoded cwd>/<session id>/`, where the shell
|
||||
/// keeps the session's `plan.md`. Polls: the first turn creates it
|
||||
/// asynchronously.
|
||||
pub(crate) fn session_dir(content: &ContentController, harness: &mut PtyHarness) -> PathBuf {
|
||||
let sessions = content.home().join(".grok").join("sessions");
|
||||
for _ in 0..100 {
|
||||
if let Ok(outer) = std::fs::read_dir(&sessions) {
|
||||
for cwd_dir in outer.flatten() {
|
||||
if let Ok(inner) = std::fs::read_dir(cwd_dir.path()) {
|
||||
for entry in inner.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
return entry.path();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
panic!("no session dir under {}", sessions.display());
|
||||
}
|
||||
|
||||
/// Spawn the pager in minimal mode against `content` at the default size.
|
||||
pub(crate) fn spawn_minimal(content: &ContentController) -> PtyHarness {
|
||||
spawn_minimal_sized(content, DEFAULT_ROWS, DEFAULT_COLS)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Taller than the terminal, so a plan held in the live region is clipped.
|
||||
const PLAN_LINES: usize = 100;
|
||||
|
||||
fn missing(harness: &mut PtyHarness, tag: &str) -> Vec<usize> {
|
||||
plan_lines_missing(harness, tag, PLAN_LINES)
|
||||
}
|
||||
|
||||
fn duplicated(harness: &mut PtyHarness, tag: &str) -> Vec<usize> {
|
||||
plan_lines_duplicated(harness, tag, PLAN_LINES)
|
||||
}
|
||||
|
||||
fn park_plan(
|
||||
content: &ContentController,
|
||||
harness: &mut PtyHarness,
|
||||
dir: &std::path::Path,
|
||||
tag: &str,
|
||||
call_id: &str,
|
||||
prompt: &str,
|
||||
) -> AgentTurnExpectation {
|
||||
std::fs::write(dir.join("plan.md"), plan_body(tag, PLAN_LINES)).expect("seed plan.md");
|
||||
let expectation = expect_tool_turn(content, call_id, "exit_plan_mode", "{}".into());
|
||||
harness
|
||||
.inject_keys(format!("{prompt}\r").as_bytes())
|
||||
.expect("submit plan prompt");
|
||||
harness
|
||||
.wait_for_text(PLAN_PARKED_SENTINEL, Duration::from_secs(60))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"plan approval never parked: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
for _ in 0..10 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
expectation
|
||||
}
|
||||
|
||||
/// Minimal's plan-approval contract: the plan body reaches NATIVE SCROLLBACK
|
||||
/// **while the approval is parked**, not only once the user answers. Users
|
||||
/// reported reading a truncated plan with nothing behind it in the scrollback;
|
||||
/// design doc §6.16.
|
||||
///
|
||||
/// Also pins the revision path: a revised plan is a fresh `exit_plan_mode` with
|
||||
/// a new `tool_call_id`, and must commit as its own block exactly once.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_parked_plan_commits_to_scrollback() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
|
||||
|
||||
// ~5x the screen, so live-region-only rendering is guaranteed to clip.
|
||||
let mut harness = spawn_minimal_sized(&content, 20, 100);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// A first turn, so the session (and its plan.md directory) exists.
|
||||
harness.inject_keys(b"go\r").expect("submit first turn");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
|
||||
.expect("first turn streams");
|
||||
let dir = session_dir(&content, &mut harness);
|
||||
|
||||
// ── plan 1, parked ──
|
||||
let _first = park_plan(
|
||||
&content,
|
||||
&mut harness,
|
||||
&dir,
|
||||
"ONE",
|
||||
"call_plan_one",
|
||||
"present the plan",
|
||||
);
|
||||
assert!(
|
||||
missing(&mut harness, "ONE").is_empty(),
|
||||
"the whole plan must be readable while the approval is parked \
|
||||
(missing {:?})\nscrollback rows: {}\nscreen:\n{}",
|
||||
missing(&mut harness, "ONE"),
|
||||
harness.scrollback_text().lines().count(),
|
||||
harness.screen_contents(),
|
||||
);
|
||||
assert!(
|
||||
duplicated(&mut harness, "ONE").is_empty(),
|
||||
"parked plan must be printed exactly once (duplicated {:?})",
|
||||
duplicated(&mut harness, "ONE"),
|
||||
);
|
||||
|
||||
// ── revise: `s` focuses the feedback input, Enter sends it ──
|
||||
harness.inject_keys(b"s").expect("request changes");
|
||||
harness.update(Duration::from_millis(400));
|
||||
let _second = park_plan(
|
||||
&content,
|
||||
&mut harness,
|
||||
&dir,
|
||||
"TWO",
|
||||
"call_plan_two",
|
||||
"make it shorter",
|
||||
);
|
||||
assert!(
|
||||
missing(&mut harness, "TWO").is_empty(),
|
||||
"the revised plan must also be readable while parked (missing {:?})",
|
||||
missing(&mut harness, "TWO"),
|
||||
);
|
||||
assert!(
|
||||
duplicated(&mut harness, "ONE").is_empty() && duplicated(&mut harness, "TWO").is_empty(),
|
||||
"a revision must not re-emit either plan (ONE {:?}, TWO {:?})",
|
||||
duplicated(&mut harness, "ONE"),
|
||||
duplicated(&mut harness, "TWO"),
|
||||
);
|
||||
|
||||
// ── approve ──
|
||||
harness.inject_keys(b"a").expect("approve");
|
||||
for _ in 0..40 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
duplicated(&mut harness, "ONE").is_empty() && duplicated(&mut harness, "TWO").is_empty(),
|
||||
"approving must not re-print the plan (ONE {:?}, TWO {:?})",
|
||||
duplicated(&mut harness, "ONE"),
|
||||
duplicated(&mut harness, "TWO"),
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
const PLAN_LINES: usize = 60;
|
||||
const TAG: &str = "QUIT";
|
||||
|
||||
/// The permanent-loss shape of the parked-plan bug users hit: quitting without
|
||||
/// answering must still leave the whole plan in the terminal. The pinned live
|
||||
/// region is repainted and never retained, so only what was committed at park
|
||||
/// time survives the process. Design doc §6.16.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_parked_plan_survives_quit() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
|
||||
|
||||
let mut harness = spawn_minimal_sized(&content, 20, 100);
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness.inject_keys(b"go\r").expect("submit first turn");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
|
||||
.expect("first turn streams");
|
||||
|
||||
let dir = session_dir(&content, &mut harness);
|
||||
std::fs::write(dir.join("plan.md"), plan_body(TAG, PLAN_LINES)).expect("seed plan.md");
|
||||
|
||||
let _expectation = expect_tool_turn(&content, "call_plan_quit", "exit_plan_mode", "{}".into());
|
||||
harness
|
||||
.inject_keys(b"present the plan\r")
|
||||
.expect("submit plan prompt");
|
||||
harness
|
||||
.wait_for_text(PLAN_PARKED_SENTINEL, Duration::from_secs(60))
|
||||
.expect("plan approval parks");
|
||||
for _ in 0..10 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// Quit without answering (Ctrl+Q arms, Ctrl+Q confirms).
|
||||
let _ = harness.inject_keys(b"\x11");
|
||||
harness.update(Duration::from_millis(300));
|
||||
let _ = harness.inject_keys(b"\x11");
|
||||
for _ in 0..40 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let missing = plan_lines_missing(&mut harness, TAG, PLAN_LINES);
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"plan must survive in the terminal after quitting while parked \
|
||||
(missing {}/{PLAN_LINES}: {missing:?})",
|
||||
missing.len(),
|
||||
);
|
||||
|
||||
let _ = harness.quit();
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
use xai_grok_pager_pty_harness::{
|
||||
InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher,
|
||||
};
|
||||
|
||||
/// Must never appear in the answer text, so the assertions can tell them apart.
|
||||
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
|
||||
|
||||
/// `crate::glyphs::accent_bar()` on a non-legacy console.
|
||||
const RAIL: &str = "\u{2503}";
|
||||
|
||||
/// Kept alive for the duration of the assertions.
|
||||
struct Turn {
|
||||
harness: PtyHarness,
|
||||
_content: ContentController,
|
||||
_expectation: InferenceExpectation,
|
||||
}
|
||||
|
||||
/// Drive one minimal turn that streams reasoning and then an answer, under
|
||||
/// `NO_COLOR=1` — the case that was 100% broken, because the `bg_blend` fade is
|
||||
/// a complete no-op under the terminal-native palette.
|
||||
async fn run_reasoning_turn(collapse_thinking: bool) -> Turn {
|
||||
// Reasoning summary deltas are a Responses-API stream shape.
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new("test-model").with_api_backend("responses"),
|
||||
])
|
||||
.await
|
||||
.expect("start content");
|
||||
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly and at some length");
|
||||
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
|
||||
let expectation = content.expect_response(
|
||||
"minimal reasoning-vs-output turn",
|
||||
InferenceRequestMatcher::foreground(InferenceEndpoint::Responses),
|
||||
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
|
||||
&reasoning,
|
||||
&answer,
|
||||
"test-model",
|
||||
)),
|
||||
);
|
||||
content.set_response(answer.clone());
|
||||
|
||||
// Ingestion is gated on this toggle, and the sandbox `$HOME` has no config.
|
||||
std::fs::create_dir_all(content.home().join(".grok")).expect("mk .grok");
|
||||
std::fs::write(
|
||||
content.home().join(".grok/config.toml"),
|
||||
"[ui]\nshow_thinking_blocks = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
if collapse_thinking {
|
||||
let grok_home = content.sandbox().grok_home().to_path_buf();
|
||||
std::fs::create_dir_all(&grok_home).expect("mk grok home");
|
||||
std::fs::write(
|
||||
grok_home.join("pager.toml"),
|
||||
"[terminal]\nminimal_collapse_thinking = true\n",
|
||||
)
|
||||
.expect("write pager.toml");
|
||||
}
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_env(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
MINIMAL_ARGS,
|
||||
&[("NO_COLOR", "1")],
|
||||
)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
harness
|
||||
.wait_for_full_text("Thought", Duration::from_secs(10))
|
||||
.expect("thinking header committed");
|
||||
Turn {
|
||||
harness,
|
||||
_content: content,
|
||||
_expectation: expectation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every styled screen row carrying `needle`, rendered as
|
||||
/// `["text" dim=… italic=…]` runs: the SGR attributes the terminal emulator
|
||||
/// actually received, not just the glyphs.
|
||||
fn styled_rows_with(harness: &PtyHarness, needle: &str) -> Vec<String> {
|
||||
harness
|
||||
.screen_styled()
|
||||
.into_iter()
|
||||
.filter(|line| line.runs.iter().any(|r| r.text.contains(needle)))
|
||||
.map(|line| {
|
||||
line.runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
format!(
|
||||
"[{:?} dim={} italic={}]",
|
||||
r.text.trim_end(),
|
||||
r.dim,
|
||||
r.italic
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reasoning must read as "not the answer" in a static native scrollback, with
|
||||
/// no blank-row separator, no indent, and under `NO_COLOR` no color delta at
|
||||
/// all. Three orthogonal cues, on the body rows and off the assistant rows.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_thinking_is_visually_distinct_from_output() {
|
||||
let Turn { mut harness, .. } = run_reasoning_turn(false).await;
|
||||
|
||||
harness
|
||||
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"reasoning body must be committed: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
eprintln!("─── minimal screen (NO_COLOR=1) ───\n{screen}\n───");
|
||||
for row in styled_rows_with(&harness, REASONING_SENTINEL) {
|
||||
eprintln!("reasoning row: {row}");
|
||||
}
|
||||
for row in styled_rows_with(&harness, MOCK_RESPONSE_SENTINEL) {
|
||||
eprintln!("answer row: {row}");
|
||||
}
|
||||
|
||||
// 1. Structural: the rail in column 0.
|
||||
let reasoning_row = screen
|
||||
.lines()
|
||||
.find(|l| l.contains(REASONING_SENTINEL))
|
||||
.unwrap_or_else(|| panic!("reasoning row on screen:\n{screen}"));
|
||||
assert!(
|
||||
reasoning_row.starts_with(RAIL),
|
||||
"reasoning must keep its accent rail: {reasoning_row:?}"
|
||||
);
|
||||
let answer_row = screen
|
||||
.lines()
|
||||
.find(|l| l.contains(MOCK_RESPONSE_SENTINEL))
|
||||
.unwrap_or_else(|| panic!("answer row on screen:\n{screen}"));
|
||||
assert!(
|
||||
!answer_row.starts_with(RAIL),
|
||||
"assistant output must not wear a rail: {answer_row:?}"
|
||||
);
|
||||
|
||||
// 2. Attributes: SGR survives NO_COLOR where a foreground blend does not.
|
||||
let runs_with = |needle: &str| -> Vec<_> {
|
||||
harness
|
||||
.screen_styled()
|
||||
.into_iter()
|
||||
.flat_map(|l| l.runs)
|
||||
.filter(|r| r.text.contains(needle))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let reasoning_runs = runs_with(REASONING_SENTINEL);
|
||||
assert!(!reasoning_runs.is_empty(), "no styled reasoning run found");
|
||||
for run in &reasoning_runs {
|
||||
assert!(run.dim, "reasoning must be dim under NO_COLOR: {run:?}");
|
||||
assert!(run.italic, "reasoning must be italic: {run:?}");
|
||||
}
|
||||
|
||||
let answer_runs = runs_with(MOCK_RESPONSE_SENTINEL);
|
||||
assert!(!answer_runs.is_empty(), "no styled answer run found");
|
||||
assert!(
|
||||
answer_runs.iter().any(|r| !r.dim && !r.italic),
|
||||
"assistant output must stay undimmed and upright: {answer_runs:?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
||||
/// The collapsed header advertises the only way back into the body, and
|
||||
/// `Ctrl+E` must honour the advertisement by re-printing it in full (K10).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_collapse_thinking_toggle_folds_and_ctrl_e_reopens() {
|
||||
let Turn { mut harness, .. } = run_reasoning_turn(true).await;
|
||||
|
||||
harness
|
||||
.wait_for_full_text("ctrl+e to expand", Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"collapsed reasoning must advertise the expand key: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
eprintln!(
|
||||
"─── collapsed ([terminal] minimal_collapse_thinking = true) ───\n{}\n───",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_full_text(REASONING_SENTINEL),
|
||||
"the body must be folded away:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
harness.inject_keys(b"\x05").expect("ctrl+e");
|
||||
harness
|
||||
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"ctrl+e must re-print the folded reasoning: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
eprintln!("─── after ctrl+e ───\n{}\n───", harness.screen_contents());
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ mod minimal_flush_left_no_hpad;
|
|||
mod minimal_help_opens_command_palette;
|
||||
mod minimal_lookup_commits_one_line_summary;
|
||||
mod minimal_new_session_keeps_history_and_resets;
|
||||
mod minimal_parked_plan_commits_to_scrollback;
|
||||
mod minimal_parked_plan_survives_quit;
|
||||
mod minimal_queue_indicator_shows_while_running;
|
||||
mod minimal_resize_preserves_committed_scrollback;
|
||||
mod minimal_settings_modal_opens_and_closes;
|
||||
|
|
@ -28,5 +30,6 @@ mod minimal_short_response_stays_on_screen;
|
|||
mod minimal_slash_dropdown_dismisses_with_esc;
|
||||
mod minimal_slash_switches_from_fullscreen;
|
||||
mod minimal_slash_switches_to_fullscreen;
|
||||
mod minimal_thinking_is_visually_distinct_from_output;
|
||||
mod minimal_transcript_opens_in_pager;
|
||||
mod minimal_transcript_pager_restore_no_artifacts;
|
||||
|
|
|
|||
|
|
@ -5270,51 +5270,23 @@ fn default_selected_permission_mouse_click_on_indicator_opens_picker_in_one_clic
|
|||
}
|
||||
}
|
||||
|
||||
/// The `/privacy` slash command's argument parser
|
||||
/// is case-insensitive and supports a deliberately-pared-down list of
|
||||
/// unambiguous-semantic aliases. The unit-level coverage lives in the
|
||||
/// slash command module; this e2e test pins the integration contract
|
||||
/// (the parser is reachable from the slash command and produces the
|
||||
/// expected `Action`).
|
||||
///
|
||||
/// Ambiguous aliases
|
||||
/// (`on/off/true/false/enable/disable`) were DROPPED because they
|
||||
/// could be read either as "turn on privacy" (=opt-out) or "turn on
|
||||
/// sharing" (=opt-in). For a privacy-critical setting we err on the
|
||||
/// side of explicit, unambiguous arguments. The test below verifies
|
||||
/// both the accept list AND the reject list.
|
||||
/// `/privacy` takes no arguments: it opens the settings page and nothing
|
||||
/// else. The alias parser it used to carry (`opt-in`, `share`, `out`, …) is
|
||||
/// gone — a one-word prompt alias could flip a privacy preference with none
|
||||
/// of the disclosure copy in front of the user, and the ambiguous forms
|
||||
/// (`on`/`off`) risked landing on the opposite of the intent.
|
||||
#[test]
|
||||
fn pr9_privacy_slash_command_parses_aliases() {
|
||||
use xai_grok_pager::slash::commands::privacy::parse_privacy_arg;
|
||||
fn pr9_privacy_slash_command_takes_no_arguments() {
|
||||
use xai_grok_pager::slash::commands::builtin_commands;
|
||||
use xai_grok_pager::slash::registry::CommandRegistry;
|
||||
|
||||
// Canonical names.
|
||||
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
|
||||
|
||||
// Case-insensitive (sample).
|
||||
assert_eq!(parse_privacy_arg("Opt-In"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("OPT-OUT"), Some(false));
|
||||
|
||||
// Unambiguous-semantic aliases (pruned list).
|
||||
assert_eq!(parse_privacy_arg("in"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("out"), Some(false));
|
||||
assert_eq!(parse_privacy_arg("share"), Some(true));
|
||||
assert_eq!(parse_privacy_arg("private"), Some(false));
|
||||
|
||||
// Ambiguous aliases MUST be rejected. `/privacy on`
|
||||
// could be read as "turn on privacy" (=opt-out, the OPPOSITE of
|
||||
// what an earlier mapping returned). For a privacy
|
||||
// setting, ambiguity = silent data-exfiltration risk.
|
||||
for ambiguous in &["on", "off", "true", "false", "enable", "disable"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(ambiguous),
|
||||
None,
|
||||
"ambiguous alias `{ambiguous}` MUST be rejected (PR 9 R1, Security Issue 10)",
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown.
|
||||
assert_eq!(parse_privacy_arg("maybe"), None);
|
||||
let reg = CommandRegistry::new(builtin_commands());
|
||||
let cmd = reg.get("privacy").expect("/privacy must be registered");
|
||||
assert!(
|
||||
!cmd.takes_args(),
|
||||
"/privacy must not advertise an argument slot"
|
||||
);
|
||||
assert_eq!(cmd.usage(), "/privacy");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue