Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
24
Cargo.lock
generated
24
Cargo.lock
generated
|
|
@ -13253,6 +13253,16 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xai-grok-extra-ca"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.24",
|
||||
"rustls",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xai-grok-hooks"
|
||||
version = "0.1.0"
|
||||
|
|
@ -13282,6 +13292,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tracing",
|
||||
"xai-grok-auth",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-sampler",
|
||||
"xai-grok-telemetry",
|
||||
"xai-grok-version",
|
||||
|
|
@ -13348,6 +13359,7 @@ dependencies = [
|
|||
"xai-computer-hub-sdk",
|
||||
"xai-file-utils",
|
||||
"xai-grok-config",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-telemetry",
|
||||
"xai-grok-tools",
|
||||
"xai-grok-version",
|
||||
|
|
@ -13419,7 +13431,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
|
|
@ -13509,7 +13521,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-pager-bin"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
@ -13685,6 +13697,7 @@ dependencies = [
|
|||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-sampling-types",
|
||||
"xai-grok-test-support",
|
||||
"xai-grok-version",
|
||||
|
|
@ -13773,7 +13786,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-shell"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
|
|
@ -13879,6 +13892,7 @@ dependencies = [
|
|||
"xai-grok-compaction",
|
||||
"xai-grok-config",
|
||||
"xai-grok-config-types",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-hooks",
|
||||
"xai-grok-http",
|
||||
"xai-grok-mcp",
|
||||
|
|
@ -14023,6 +14037,7 @@ dependencies = [
|
|||
"xai-grok-auth",
|
||||
"xai-grok-config",
|
||||
"xai-grok-env",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-sampler",
|
||||
"xai-grok-secrets",
|
||||
"xai-mixpanel",
|
||||
|
|
@ -14123,6 +14138,7 @@ dependencies = [
|
|||
"xai-file-utils",
|
||||
"xai-grok-config",
|
||||
"xai-grok-env",
|
||||
"xai-grok-extra-ca",
|
||||
"xai-grok-sandbox",
|
||||
"xai-grok-test-support",
|
||||
"xai-grok-tools-api",
|
||||
|
|
@ -14177,7 +14193,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-version"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ members = [
|
|||
"crates/codegen/xai-grok-config",
|
||||
"crates/codegen/xai-grok-config-types",
|
||||
"crates/codegen/xai-grok-env",
|
||||
"crates/codegen/xai-grok-extra-ca",
|
||||
"crates/codegen/xai-grok-hooks",
|
||||
"crates/codegen/xai-grok-http",
|
||||
"crates/codegen/xai-grok-markdown",
|
||||
|
|
@ -289,6 +290,7 @@ xai-grok-auth = { path = "crates/codegen/xai-grok-auth" }
|
|||
xai-grok-config = { path = "crates/codegen/xai-grok-config" }
|
||||
xai-grok-config-types = { path = "crates/codegen/xai-grok-config-types" }
|
||||
xai-grok-env = { path = "crates/codegen/xai-grok-env" }
|
||||
xai-grok-extra-ca = { path = "crates/codegen/xai-grok-extra-ca" }
|
||||
xai-grok-http = { path = "crates/codegen/xai-grok-http" }
|
||||
xai-grok-markdown = { path = "crates/codegen/xai-grok-markdown" }
|
||||
xai-grok-markdown-core = { path = "crates/codegen/xai-grok-markdown-core" }
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
6372e41d828b8a6ee82c29e01a69e27ec895cca9
|
||||
2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
|
||||
|
|
|
|||
25
clippy.toml
25
clippy.toml
|
|
@ -6,23 +6,22 @@
|
|||
# TODO: remove after https://github.com/hyperium/tonic/issues/2253 fixed.
|
||||
large-error-threshold = 256
|
||||
|
||||
# Ban raw canonicalize: on Windows, std/tokio canonicalize return verbatim
|
||||
# `\\?\C:\...` paths that break external tools (git rejects them as clone
|
||||
# destinations), leak into model prompt context, and poison path-equality
|
||||
# keys. Use `dunce::canonicalize` (identical to std on Unix/macOS; strips the
|
||||
# verbatim prefix on Windows when safely representable). For async contexts
|
||||
# in xai-grok-tools, use the blessed helpers in `crate::util::fs`.
|
||||
# Ban raw canonicalize: on Windows it returns verbatim `\\?\C:\...` paths that
|
||||
# break external tools, leak into prompts, and poison path-equality keys.
|
||||
# `dunce::canonicalize` keeps the verbatim form for paths it cannot simplify
|
||||
# (over 260 chars, device names), so containment checks there fail closed.
|
||||
#
|
||||
# Caveat: dunce keeps the verbatim form for paths it cannot safely simplify
|
||||
# (notably > 260 chars or reserved device names), so prefix checks between two
|
||||
# independently canonicalized paths can mismatch on Windows for very long
|
||||
# paths (containment checks then fail closed).
|
||||
# Ban raw child-process spawning: an unenrolled child outlives the session that
|
||||
# started it, while an enrolled one dies with its scope. Allow with a reason
|
||||
# where a child is waited on or deliberately detached.
|
||||
#
|
||||
# Enforcement boundary: this ban is applied by the per-crate cargo clippy
|
||||
# presubmits only — the Bazel lint aspect pins the repo-root //:clippy.toml,
|
||||
# so Bazel-only crates (currently xai-coding-env) need manual vigilance.
|
||||
# Enforced by `cargo clippy` and `just lint-rs`. The Bazel lint aspect pins the
|
||||
# repo-root config instead, so crates this file does not reach carry a
|
||||
# crate-level allow.
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use dunce::canonicalize" },
|
||||
{ path = "std::path::Path::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use dunce::canonicalize" },
|
||||
{ path = "tokio::fs::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use xai_grok_tools::util::fs helpers or spawn_blocking + dunce::canonicalize" },
|
||||
{ path = "std::process::Command::spawn", reason = "an unenrolled child outlives its session; use xai_tty_utils::ProcessScope::enroll" },
|
||||
{ path = "tokio::process::Command::spawn", reason = "an unenrolled child outlives its session; use xai_tty_utils::ProcessScope::enroll" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -468,6 +468,7 @@ async fn record_last_turn_usage_round_trip() {
|
|||
total_tokens: 1290,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 800,
|
||||
cache_creation_prompt_tokens: 0,
|
||||
};
|
||||
h.handle.record_last_turn_usage(usage.clone());
|
||||
|
||||
|
|
@ -483,6 +484,7 @@ async fn record_last_turn_usage_round_trip() {
|
|||
total_tokens: 10000,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
cache_creation_prompt_tokens: 0,
|
||||
};
|
||||
h.handle.record_last_turn_usage(next);
|
||||
let got2 = h
|
||||
|
|
@ -504,6 +506,7 @@ async fn prompt_usage_ledger_via_handle_resets_and_clears() {
|
|||
total_tokens: 12,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
cache_creation_prompt_tokens: 0,
|
||||
};
|
||||
|
||||
let h = TestHarness::new();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub struct UsageTotals {
|
|||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_read_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub reasoning_tokens: u64,
|
||||
pub model_calls: u64,
|
||||
pub api_duration_ms: u64,
|
||||
|
|
@ -52,6 +53,7 @@ impl UsageTotals {
|
|||
input_tokens: u64::from(usage.prompt_tokens),
|
||||
output_tokens: u64::from(usage.completion_tokens),
|
||||
cached_read_tokens: u64::from(usage.cached_prompt_tokens),
|
||||
cache_creation_tokens: u64::from(usage.cache_creation_prompt_tokens),
|
||||
reasoning_tokens: u64::from(usage.reasoning_tokens),
|
||||
model_calls: 1,
|
||||
api_duration_ms: api_duration_ms.unwrap_or(0),
|
||||
|
|
@ -73,6 +75,7 @@ impl UsageTotals {
|
|||
input_tokens,
|
||||
output_tokens,
|
||||
cached_read_tokens,
|
||||
cache_creation_tokens,
|
||||
reasoning_tokens,
|
||||
model_calls,
|
||||
api_duration_ms,
|
||||
|
|
@ -82,6 +85,9 @@ impl UsageTotals {
|
|||
self.input_tokens = self.input_tokens.saturating_add(*input_tokens);
|
||||
self.output_tokens = self.output_tokens.saturating_add(*output_tokens);
|
||||
self.cached_read_tokens = self.cached_read_tokens.saturating_add(*cached_read_tokens);
|
||||
self.cache_creation_tokens = self
|
||||
.cache_creation_tokens
|
||||
.saturating_add(*cache_creation_tokens);
|
||||
self.reasoning_tokens = self.reasoning_tokens.saturating_add(*reasoning_tokens);
|
||||
self.model_calls = self.model_calls.saturating_add(*model_calls);
|
||||
self.api_duration_ms = self.api_duration_ms.saturating_add(*api_duration_ms);
|
||||
|
|
@ -157,6 +163,7 @@ mod tests {
|
|||
total_tokens: 999_999,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
cache_creation_prompt_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1975,6 +1975,7 @@ pub mod gc {
|
|||
#[test]
|
||||
fn is_pid_alive_false_for_reaped_child() {
|
||||
// A fully reaped child's pid is gone (ESRCH) and must read as dead.
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
|
||||
let mut child = std::process::Command::new("true")
|
||||
.spawn()
|
||||
.expect("spawn `true`");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Throttled automatic worktree GC (feature `metadata`).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
|
|
@ -9,7 +9,6 @@ use crate::CleanupReport;
|
|||
use crate::api::gc::{GcOptions, GcReport, age_path_enabled, gc_worktrees};
|
||||
use crate::db::{ListFilter, WorktreeDb, WorktreeKind, now_epoch_secs, resolve_grok_home};
|
||||
use crate::discovery::{RebuildReport, rebuild_worktree_db};
|
||||
use crate::git::checkout::git_command;
|
||||
|
||||
pub const META_LAST_AUTO_GC_AT: &str = "last_auto_gc_at";
|
||||
/// Independent throttle stamp for optional DB rebuild (not shared with GC).
|
||||
|
|
@ -58,7 +57,7 @@ pub struct WorktreeAutoGcLayer {
|
|||
pub dry_run: Option<bool>,
|
||||
pub include_orphan_snapshots: Option<bool>,
|
||||
pub max_age_by_kind: BTreeMap<WorktreeKind, Option<u64>>,
|
||||
/// Optional discovery rebuild + stale `.git/worktrees/` prune (default off).
|
||||
/// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off).
|
||||
pub include_rebuild: Option<bool>,
|
||||
/// Independent rebuild throttle; absent ⇒ 24h.
|
||||
pub rebuild_min_interval_secs: Option<u64>,
|
||||
|
|
@ -459,7 +458,7 @@ pub fn maybe_auto_gc(db: &WorktreeDb, auto_opts: &AutoGcOptions) -> Result<AutoG
|
|||
|
||||
let (overlay, btrfs) = run_orphan_cleaners(dry_run, auto_opts.include_orphan_snapshots);
|
||||
|
||||
// Prune each full pass when opted in (cheap vs discovery; not rebuild-throttled).
|
||||
// Scrub each full pass when opted in (cheap vs discovery; not rebuild-throttled).
|
||||
let stale_registrations_cleaned = if include_rebuild && !dry_run {
|
||||
prune_stale_git_worktree_registrations(&prune_repos)
|
||||
} else {
|
||||
|
|
@ -633,65 +632,29 @@ fn collect_source_repos_for_prune(db: &WorktreeDb) -> BTreeSet<PathBuf> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Scrub stale grok-owned registrations from each known source repo,
|
||||
/// scoped to worktrees under the grok home to prove ownership (see
|
||||
/// [`crate::git::remove_stale_worktree_registrations`] for why a blanket
|
||||
/// `git worktree prune` is unsafe here).
|
||||
fn prune_stale_git_worktree_registrations(repos: &BTreeSet<PathBuf>) -> u64 {
|
||||
let Ok(grok_home) = resolve_grok_home() else {
|
||||
tracing::warn!("auto worktree registration scrub skipped: grok home unresolved");
|
||||
return 0;
|
||||
};
|
||||
let cleaned: u64 = repos
|
||||
.iter()
|
||||
.filter(|repo| repo.is_dir())
|
||||
.map(|repo| prune_stale_registrations_in_repo(repo))
|
||||
.map(|repo| crate::git::remove_stale_worktree_registrations_under(repo, &grok_home))
|
||||
.fold(0u64, u64::saturating_add);
|
||||
if cleaned > 0 {
|
||||
tracing::info!(
|
||||
stale_registrations_cleaned = cleaned,
|
||||
"auto worktree stale git registrations pruned"
|
||||
"auto worktree stale git registrations scrubbed"
|
||||
);
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
fn count_git_worktree_registrations(git_worktrees_dir: &Path) -> u64 {
|
||||
let Ok(entries) = std::fs::read_dir(git_worktrees_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.count() as u64
|
||||
}
|
||||
|
||||
fn prune_stale_registrations_in_repo(source_repo: &Path) -> u64 {
|
||||
let git_worktrees = source_repo.join(".git").join("worktrees");
|
||||
let before = count_git_worktree_registrations(&git_worktrees);
|
||||
|
||||
let output = git_command()
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(source_repo)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let after = count_git_worktree_registrations(&git_worktrees);
|
||||
before.saturating_sub(after)
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
status = %o.status,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr),
|
||||
"git worktree prune failed"
|
||||
);
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
error = %e,
|
||||
"git worktree prune failed to spawn"
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_orphan_cleaners(
|
||||
dry_run: bool,
|
||||
include_orphan_snapshots: bool,
|
||||
|
|
@ -735,6 +698,7 @@ pub fn maybe_auto_gc_default() -> Result<AutoGcReport> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{WorktreeRecord, WorktreeStatus};
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
|
@ -2095,6 +2059,48 @@ mod tests {
|
|||
assert!(count_regs(&repo) < before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_foreign_registrations_outside_grok_home() {
|
||||
let _g = env_guard();
|
||||
clear_auto_gc_env();
|
||||
let fx = crate::db::GrokHomeFixture::new();
|
||||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||||
let repo = fx.home.join("foreign-src");
|
||||
|
||||
let user_dir = tempfile::TempDir::new().unwrap();
|
||||
let user_wt = user_dir.path().join("user-wt");
|
||||
plant_stale_git_worktree(&repo, &user_wt);
|
||||
let before = count_regs(&repo);
|
||||
assert!(before >= 1);
|
||||
|
||||
let tracked = fx.home.join("foreign-tracked");
|
||||
std::fs::create_dir_all(&tracked).unwrap();
|
||||
let mut rec = make_rec("fk-t", tracked, WorktreeKind::Session, now_epoch_secs());
|
||||
rec.source_repo = repo.clone();
|
||||
db.register(&rec).unwrap();
|
||||
|
||||
let report = maybe_auto_gc(
|
||||
&db,
|
||||
&AutoGcOptions {
|
||||
min_interval_secs: 0,
|
||||
include_orphan_snapshots: false,
|
||||
include_rebuild: true,
|
||||
rebuild_min_interval_secs: 0,
|
||||
..AutoGcOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report.stale_registrations_cleaned, 0,
|
||||
"foreign registrations must never be scrubbed"
|
||||
);
|
||||
assert_eq!(
|
||||
count_regs(&repo),
|
||||
before,
|
||||
"user registrations outside grok home must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_unparseable_stamp_fails_open() {
|
||||
let _g = env_guard();
|
||||
|
|
|
|||
|
|
@ -445,10 +445,6 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
) -> Result<WorktreeReport> {
|
||||
let dest_str = dest.to_string_lossy();
|
||||
|
||||
// A previously-disposed worktree can leave a stale registration for this
|
||||
// path; prune it so re-adding the original `subagent-<id>` dir succeeds.
|
||||
snapshot_git(source_repo, &["worktree", "prune"], &[])?;
|
||||
|
||||
// The snapshot's first parent is the original base. Resolve it, then confirm
|
||||
// the object is actually present — a parent-repo `git reset --hard` + gc can
|
||||
// leave the parent pointer dangling, which `rev-parse` alone would not catch.
|
||||
|
|
@ -478,11 +474,14 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
// A prior rehydrate may have failed after `worktree add` and left a partial dir; remove it so this attempt starts clean (worktree add fails on an existing path).
|
||||
if dest.exists() {
|
||||
let _ = crate::remove_worktree(dest);
|
||||
let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]);
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
// `git worktree add` refuses a path another registration still claims,
|
||||
// so scrub `dest`'s stale registration (previously-disposed worktree, or
|
||||
// the raw `remove_dir_all` fallback above) before adding.
|
||||
crate::git::remove_stale_worktree_registration(source_repo, dest);
|
||||
snapshot_git(
|
||||
source_repo,
|
||||
&[
|
||||
|
|
@ -509,7 +508,7 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
Err(e) => {
|
||||
// Best-effort cleanup; preserve the original error.
|
||||
let _ = crate::remove_worktree(dest);
|
||||
let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]);
|
||||
crate::git::remove_stale_worktree_registration(source_repo, dest);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
|
@ -1121,6 +1120,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Rehydrate must clear its own stale registration (so re-adding the
|
||||
/// same path succeeds) while leaving every other entry alone — its
|
||||
/// cleanup once pruned repo-wide and destroyed user registrations whose
|
||||
/// paths were not visible from the container mount namespace.
|
||||
#[test]
|
||||
fn test_rehydrate_clears_only_its_own_stale_registration() {
|
||||
xai_test_utils::require_git!();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (repo_path, wt) = repo_with_worktree(&temp);
|
||||
|
||||
std::fs::write(wt.join("tracked.txt"), "edited").unwrap();
|
||||
let snap = snapshot_worktree_to_ref(&wt, "refs/grok/snapshots/stalereg", "stale").unwrap();
|
||||
crate::remove_worktree(&wt).unwrap();
|
||||
|
||||
rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap();
|
||||
let registration = repo_path
|
||||
.join(".git")
|
||||
.join("worktrees")
|
||||
.join(wt.file_name().unwrap());
|
||||
assert!(registration.is_dir(), "linked registration expected");
|
||||
|
||||
let user_wt = temp.path().join("user-wt");
|
||||
git_capture_in(
|
||||
&repo_path,
|
||||
&["worktree", "add", "--detach", user_wt.to_str().unwrap()],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::rename(&user_wt, temp.path().join("user-wt-hidden")).unwrap();
|
||||
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let report = rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap();
|
||||
assert_eq!(report.worktree_path, wt);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(wt.join("tracked.txt")).unwrap(),
|
||||
"edited"
|
||||
);
|
||||
assert!(
|
||||
repo_path.join(".git/worktrees/user-wt").exists(),
|
||||
"user registration must survive rehydrate even when its path is hidden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_snapshot_to_repo_makes_standalone_ref_durable() {
|
||||
xai_test_utils::require_git!();
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@ pub(crate) use discovery::{find_worktree_root, get_head_commit};
|
|||
pub(crate) use index::{copy_git_index, update_index_stats};
|
||||
pub(crate) use status::get_modified_files;
|
||||
pub(crate) use worktree::worktree_add_no_checkout;
|
||||
pub use worktree::{
|
||||
StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations,
|
||||
remove_stale_worktree_registrations_under,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Git worktree operations.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
|
|
@ -28,3 +28,372 @@ pub(crate) fn worktree_add_no_checkout(source: &Path, dest: &str, git_ref: &str)
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which stale registrations [`remove_stale_worktree_registrations`] removes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum StaleWorktreeMatch<'a> {
|
||||
/// Exactly the registration whose recorded worktree path is this path.
|
||||
Path(&'a Path),
|
||||
/// Every registration whose recorded worktree path is under this prefix
|
||||
/// (e.g. a tool-owned base directory, proving ownership of the entries).
|
||||
UnderPrefix(&'a Path),
|
||||
}
|
||||
|
||||
/// Remove stale `.git/worktrees/<id>` registrations matching `match_rule`.
|
||||
///
|
||||
/// Deliberately not `git worktree prune`: prune deletes every registration
|
||||
/// whose worktree path is not visible from the current mount namespace (git
|
||||
/// applies no expiry protection to that case) and deletes `.git/worktrees`
|
||||
/// itself once emptied — under a container that does not mount the user's
|
||||
/// linked worktrees, that wiped them all. Best-effort: failures are logged,
|
||||
/// never returned. Returns the number of registrations removed (git suffixes
|
||||
/// ids on basename collisions, so an id may differ from the basename).
|
||||
pub fn remove_stale_worktree_registrations(
|
||||
source_repo: &Path,
|
||||
match_rule: StaleWorktreeMatch<'_>,
|
||||
) -> u64 {
|
||||
if let StaleWorktreeMatch::Path(p) = match_rule
|
||||
&& p.exists()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
let common_dir = match git_command()
|
||||
.current_dir(source_repo)
|
||||
.args(["rev-parse", "--git-common-dir"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {
|
||||
let path = PathBuf::from(String::from_utf8_lossy(&o.stdout).trim());
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
source_repo.join(path)
|
||||
}
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
stderr = %String::from_utf8_lossy(&o.stderr),
|
||||
"stale registration scrub skipped: git rev-parse --git-common-dir failed"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
error = %e,
|
||||
"stale registration scrub skipped: git failed to spawn"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(common_dir.join("worktrees")) else {
|
||||
return 0;
|
||||
};
|
||||
let normalized_target = match match_rule {
|
||||
StaleWorktreeMatch::Path(p) | StaleWorktreeMatch::UnderPrefix(p) => normalized_for_match(p),
|
||||
};
|
||||
let mut removed = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let registration = entry.path();
|
||||
if !registration.is_dir() || registration.join("locked").exists() {
|
||||
continue;
|
||||
}
|
||||
let Ok(backlink) = std::fs::read_to_string(registration.join("gitdir")) else {
|
||||
continue;
|
||||
};
|
||||
// The backlink names `<worktree>/.git`; under
|
||||
// `worktree.useRelativePaths` (git >= 2.48) it is relative to the
|
||||
// registration dir, not the CWD.
|
||||
let backlink_path = Path::new(backlink.trim());
|
||||
let backlink_abs = if backlink_path.is_relative() {
|
||||
registration.join(backlink_path)
|
||||
} else {
|
||||
backlink_path.to_path_buf()
|
||||
};
|
||||
let Some(recorded) = backlink_abs.parent() else {
|
||||
continue;
|
||||
};
|
||||
if recorded.exists() {
|
||||
continue;
|
||||
}
|
||||
let recorded = normalized_for_match(recorded);
|
||||
let matched = match match_rule {
|
||||
StaleWorktreeMatch::Path(_) => recorded == normalized_target,
|
||||
StaleWorktreeMatch::UnderPrefix(_) => recorded.starts_with(&normalized_target),
|
||||
};
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_dir_all(®istration) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
registration = %registration.display(),
|
||||
worktree = %recorded.display(),
|
||||
"removed stale worktree registration"
|
||||
);
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
registration = %registration.display(),
|
||||
error = %e,
|
||||
"failed to remove stale worktree registration"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// [`remove_stale_worktree_registrations`] scoped to exactly one worktree path.
|
||||
pub fn remove_stale_worktree_registration(source_repo: &Path, worktree_path: &Path) -> u64 {
|
||||
remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::Path(worktree_path))
|
||||
}
|
||||
|
||||
/// [`remove_stale_worktree_registrations`] scoped to a tool-owned base directory.
|
||||
pub fn remove_stale_worktree_registrations_under(source_repo: &Path, prefix: &Path) -> u64 {
|
||||
remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::UnderPrefix(prefix))
|
||||
}
|
||||
|
||||
/// Canonicalize the deepest existing ancestor and re-append the missing
|
||||
/// tail: git records the realpath at `worktree add` time, so a symlinked
|
||||
/// spelling must compare equal even after the path itself is deleted.
|
||||
fn normalized_for_match(path: &Path) -> PathBuf {
|
||||
let mut missing = Vec::new();
|
||||
let mut cursor = path;
|
||||
loop {
|
||||
if let Ok(canonical) = dunce::canonicalize(cursor) {
|
||||
let mut result = canonical;
|
||||
for component in missing.iter().rev() {
|
||||
result.push(component);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
match (cursor.parent(), cursor.file_name()) {
|
||||
(Some(parent), Some(name)) => {
|
||||
missing.push(name.to_os_string());
|
||||
cursor = parent;
|
||||
}
|
||||
_ => return path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("run git");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn init_repo_with_worktrees() -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
run_git(&repo, &["init"]);
|
||||
run_git(&repo, &["config", "user.email", "t@test"]);
|
||||
run_git(&repo, &["config", "user.name", "t"]);
|
||||
std::fs::write(repo.join("f.txt"), b"x").unwrap();
|
||||
run_git(&repo, &["add", "f.txt"]);
|
||||
run_git(&repo, &["commit", "-m", "init"]);
|
||||
(tmp, repo)
|
||||
}
|
||||
|
||||
fn add_worktree(repo: &Path, wt: &Path) {
|
||||
run_git(
|
||||
repo,
|
||||
&["worktree", "add", "--detach", wt.to_str().unwrap(), "HEAD"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_matching_stale_registration() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let git_worktrees = repo.join(".git").join("worktrees");
|
||||
|
||||
let target = tmp.path().join("target-wt");
|
||||
add_worktree(&repo, &target);
|
||||
std::fs::remove_dir_all(&target).unwrap();
|
||||
|
||||
let hidden = tmp.path().join("hidden-wt");
|
||||
add_worktree(&repo, &hidden);
|
||||
std::fs::rename(&hidden, tmp.path().join("hidden-wt-moved")).unwrap();
|
||||
|
||||
std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &target);
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(!git_worktrees.join("target-wt").exists());
|
||||
assert!(
|
||||
git_worktrees.join("hidden-wt").exists(),
|
||||
"non-matching registration must survive even when its path is gone"
|
||||
);
|
||||
assert!(git_worktrees.join("bare-entry").exists());
|
||||
assert!(git_worktrees.exists());
|
||||
}
|
||||
|
||||
/// Rewrite a registration's `gitdir` backlink to the relative layout
|
||||
/// `worktree.useRelativePaths` (git >= 2.48) produces, without requiring
|
||||
/// that git version on the test host.
|
||||
fn make_backlink_relative(repo: &Path, reg_name: &str, worktree: &Path) {
|
||||
let reg_dir = repo.join(".git").join("worktrees").join(reg_name);
|
||||
let target = worktree.join(".git");
|
||||
let mut ups = PathBuf::new();
|
||||
let mut cursor = reg_dir.as_path();
|
||||
loop {
|
||||
if let Ok(rest) = target.strip_prefix(cursor) {
|
||||
std::fs::write(
|
||||
reg_dir.join("gitdir"),
|
||||
format!("{}\n", ups.join(rest).display()),
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
cursor = cursor.parent().expect("shared ancestor");
|
||||
ups.push("..");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_backlink_against_registration_dir() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
|
||||
let stale = tmp.path().join("rel-stale");
|
||||
add_worktree(&repo, &stale);
|
||||
make_backlink_relative(&repo, "rel-stale", &stale);
|
||||
std::fs::remove_dir_all(&stale).unwrap();
|
||||
|
||||
let live = tmp.path().join("rel-live");
|
||||
add_worktree(&repo, &live);
|
||||
make_backlink_relative(&repo, "rel-live", &live);
|
||||
|
||||
let removed_live = remove_stale_worktree_registrations_under(&repo, tmp.path());
|
||||
assert_eq!(removed_live, 1, "only the stale relative entry is removed");
|
||||
assert!(!repo.join(".git/worktrees/rel-stale").exists());
|
||||
assert!(
|
||||
repo.join(".git/worktrees/rel-live").exists(),
|
||||
"live worktree with relative backlink must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_registration_when_worktree_still_exists() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let wt = tmp.path().join("live-wt");
|
||||
add_worktree(&repo, &wt);
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &wt);
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert!(repo.join(".git/worktrees/live-wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_locked_registration() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let wt = tmp.path().join("locked-wt");
|
||||
add_worktree(&repo, &wt);
|
||||
run_git(&repo, &["worktree", "lock", wt.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &wt);
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert!(repo.join(".git/worktrees/locked-wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_prefix_removes_only_owned_stale_registrations() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let git_worktrees = repo.join(".git").join("worktrees");
|
||||
|
||||
let owned_base = tmp.path().join("owned-base");
|
||||
let owned_stale = owned_base.join("instance").join("wt-stale");
|
||||
let owned_locked = owned_base.join("instance").join("wt-locked");
|
||||
let owned_live = owned_base.join("instance").join("wt-live");
|
||||
std::fs::create_dir_all(owned_stale.parent().unwrap()).unwrap();
|
||||
add_worktree(&repo, &owned_stale);
|
||||
add_worktree(&repo, &owned_locked);
|
||||
add_worktree(&repo, &owned_live);
|
||||
std::fs::remove_dir_all(&owned_stale).unwrap();
|
||||
run_git(&repo, &["worktree", "lock", owned_locked.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&owned_locked).unwrap();
|
||||
|
||||
let foreign = tmp.path().join("foreign-wt");
|
||||
add_worktree(&repo, &foreign);
|
||||
std::fs::rename(&foreign, tmp.path().join("foreign-wt-moved")).unwrap();
|
||||
|
||||
std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registrations_under(&repo, &owned_base);
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(!git_worktrees.join("wt-stale").exists());
|
||||
assert!(
|
||||
git_worktrees.join("wt-locked").exists(),
|
||||
"locked registration must survive even when owned and stale"
|
||||
);
|
||||
assert!(
|
||||
git_worktrees.join("wt-live").exists(),
|
||||
"live owned registration must survive"
|
||||
);
|
||||
assert!(
|
||||
git_worktrees.join("foreign-wt").exists(),
|
||||
"foreign registration must survive even when its path is hidden"
|
||||
);
|
||||
assert!(git_worktrees.join("bare-entry").exists());
|
||||
assert!(git_worktrees.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn under_prefix_matches_symlinked_base_spelling() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
|
||||
let real_base = tmp.path().join("real-base");
|
||||
std::fs::create_dir(&real_base).unwrap();
|
||||
let wt = real_base.join("wt");
|
||||
add_worktree(&repo, &wt);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let alias_base = tmp.path().join("alias-base");
|
||||
std::os::unix::fs::symlink(&real_base, &alias_base).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registrations_under(&repo, &alias_base);
|
||||
assert_eq!(removed, 1, "symlinked base spelling must match");
|
||||
assert!(!repo.join(".git/worktrees/wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_across_symlinked_parent_spelling() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let real_parent = tmp.path().join("real-parent");
|
||||
std::fs::create_dir(&real_parent).unwrap();
|
||||
let wt = real_parent.join("wt");
|
||||
add_worktree(&repo, &wt);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = tmp.path().join("alias-parent");
|
||||
std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
|
||||
let removed = remove_stale_worktree_registration(&repo, &alias.join("wt"));
|
||||
assert_eq!(removed, 1, "symlinked spelling of the parent must match");
|
||||
assert!(!repo.join(".git/worktrees/wt").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ pub use discovery::{RebuildReport, discover_worktrees, rebuild_worktree_db};
|
|||
pub use git::checkout::{
|
||||
rehydrate_worktree_from_ref, snapshot_worktree_to_ref, transfer_snapshot_to_repo,
|
||||
};
|
||||
pub use git::{
|
||||
StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations,
|
||||
remove_stale_worktree_registrations_under,
|
||||
};
|
||||
pub use sync::{SourceDirtyState, SyncReport, WorktreeSync, collect_source_dirty_state};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use worktree::execute::cleanup_snapshot_git_state;
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ fn replay_staged_changes(
|
|||
// <mode> SP <hex-hash> TAB <path> NUL
|
||||
if !staged_adds.is_empty() {
|
||||
use std::io::Write;
|
||||
#[allow(clippy::disallowed_methods)] // git command, waited on below
|
||||
let mut child = git_command()
|
||||
.current_dir(worktree)
|
||||
.args(["update-index", "-z", "--index-info"])
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ impl std::fmt::Debug for EventWriter {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::types::{
|
||||
EVENT_SCHEMA_VERSION, Event, SessionRelationship, TurnOutcomeLabel,
|
||||
EVENT_SCHEMA_VERSION, Event, SessionRelationship, ToolOutcome, TurnOutcomeLabel,
|
||||
};
|
||||
|
||||
fn _assert_event_writer_is_send_sync_clone()
|
||||
|
|
@ -116,6 +116,13 @@ mod tests {
|
|||
redirect_kind: None,
|
||||
});
|
||||
writer.emit(Event::FirstToken);
|
||||
writer.emit(Event::ToolCompleted {
|
||||
tool_name: "bash".into(),
|
||||
duration_ms: 1500,
|
||||
outcome: ToolOutcome::Success,
|
||||
tool_call_id: "call_xyz".into(),
|
||||
source: crate::events::types::ToolCompletedSource::Shell,
|
||||
});
|
||||
writer.emit(Event::TurnEnded {
|
||||
outcome: TurnOutcomeLabel::Completed,
|
||||
cancellation_category: None,
|
||||
|
|
@ -124,7 +131,7 @@ mod tests {
|
|||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = text.trim().split('\n').collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines.len(), 4);
|
||||
|
||||
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(first["type"], "turn_started");
|
||||
|
|
@ -135,9 +142,19 @@ mod tests {
|
|||
assert_eq!(second["type"], "first_token");
|
||||
|
||||
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
|
||||
assert_eq!(third["type"], "turn_ended");
|
||||
assert_eq!(third["outcome"], "completed");
|
||||
assert!(third.get("cancellation_category").is_none());
|
||||
assert_eq!(third["type"], "tool_completed");
|
||||
assert_eq!(third["tool_name"], "bash");
|
||||
assert_eq!(third["duration_ms"], 1500);
|
||||
assert_eq!(third["tool_call_id"], "call_xyz");
|
||||
assert!(
|
||||
third.get("source").is_none(),
|
||||
"shell ToolCompleted must omit source"
|
||||
);
|
||||
|
||||
let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap();
|
||||
assert_eq!(fourth["type"], "turn_ended");
|
||||
assert_eq!(fourth["outcome"], "completed");
|
||||
assert!(fourth.get("cancellation_category").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ pub use log::EventWriter;
|
|||
pub use tracker::EventTracker;
|
||||
pub use types::{
|
||||
CancellationCategory, EVENT_SCHEMA_VERSION, Event, McpConfigServer, McpErrorCategory,
|
||||
PermissionDecision, Phase, SessionRelationship, ToolOutcome, TurnOutcomeLabel,
|
||||
PermissionDecision, Phase, SessionRelationship, ToolCompletedSource, ToolOutcome,
|
||||
TurnOutcomeLabel,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,12 +5,21 @@ use std::time::Instant;
|
|||
use super::log::EventWriter;
|
||||
use super::types::{CancellationCategory, Event, RedirectKind, TurnOutcomeLabel};
|
||||
|
||||
/// In-flight tool for cancel telemetry. Duration is the dispatch wall already
|
||||
/// measured, so cancel can reuse it instead of re-timing post-flight.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveTool {
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
dispatch_duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Per-session event state. `!Send` — lives on the session actor.
|
||||
/// Background tasks use `tracker.writer()` to get a `Clone + Send + Sync` handle.
|
||||
pub struct EventTracker {
|
||||
writer: EventWriter,
|
||||
turn_ended_emitted: Cell<bool>,
|
||||
active_tool: RefCell<Option<(String, Instant)>>,
|
||||
active_tool: RefCell<Option<ActiveTool>>,
|
||||
turn_tool_count: Cell<u32>,
|
||||
/// Cross-turn one-shot: the *fatal* user-interrupt cause that cancelled the
|
||||
/// most recent turn (set by the cancel paths), consumed by the *next* real
|
||||
|
|
@ -42,7 +51,7 @@ impl std::fmt::Debug for EventTracker {
|
|||
.field("writer", &self.writer)
|
||||
.field("turn_ended_emitted", &self.turn_ended_emitted.get())
|
||||
.field("turn_tool_count", &self.turn_tool_count.get())
|
||||
.field("active_tool", &active_tool.as_ref().map(|(name, _)| name))
|
||||
.field("active_tool", &*active_tool)
|
||||
.field(
|
||||
"prior_interrupt_category",
|
||||
&self.prior_interrupt_category.get(),
|
||||
|
|
@ -101,12 +110,21 @@ impl EventTracker {
|
|||
});
|
||||
}
|
||||
|
||||
/// Set the active tool for cancellation tracking and return the start instant.
|
||||
pub fn tool_started(&self, tool_name: String) -> Instant {
|
||||
let now = Instant::now();
|
||||
*self.active_tool.borrow_mut() = Some((tool_name, now));
|
||||
/// Mark a tool as active for cancellation tracking.
|
||||
///
|
||||
/// `dispatch_duration_ms` is the wall time already measured for this call, so
|
||||
/// a cancel can report it rather than re-measure from post-flight.
|
||||
pub fn tool_started(&self, tool_name: String, tool_call_id: String, dispatch_duration_ms: u64) {
|
||||
let is_new = self.active_tool.borrow().is_none();
|
||||
*self.active_tool.borrow_mut() = Some(ActiveTool {
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
dispatch_duration_ms,
|
||||
});
|
||||
// Re-entry (e.g. after reauth adds retry wall time) only refreshes duration.
|
||||
if is_new {
|
||||
self.turn_tool_count.set(self.turn_tool_count.get() + 1);
|
||||
now
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_count_this_turn(&self) -> u32 {
|
||||
|
|
@ -123,12 +141,17 @@ impl EventTracker {
|
|||
|
||||
/// Cancel in-flight tool and emit `ToolCompleted(cancelled)`.
|
||||
/// Called from `cancel_running_task()` before `turn_ended`.
|
||||
///
|
||||
/// A tool cancelled while still dispatching was never marked active, so it
|
||||
/// gets no `tool_completed` row at all.
|
||||
pub fn cancel_active_tool(&self) {
|
||||
if let Some((tool_name, start)) = self.active_tool.borrow_mut().take() {
|
||||
if let Some(tool) = self.active_tool.borrow_mut().take() {
|
||||
self.emit(Event::ToolCompleted {
|
||||
tool_name,
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
tool_name: tool.tool_name,
|
||||
duration_ms: tool.dispatch_duration_ms,
|
||||
outcome: super::types::ToolOutcome::Cancelled,
|
||||
tool_call_id: tool.tool_call_id,
|
||||
source: super::types::ToolCompletedSource::Shell,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,18 @@ pub enum Event {
|
|||
},
|
||||
ToolCompleted {
|
||||
tool_name: String,
|
||||
/// Dispatch wall time; a cancel row reuses the duration measured at dispatch.
|
||||
duration_ms: u64,
|
||||
outcome: ToolOutcome,
|
||||
/// Model/ACP tool call id; matches the conversation's `tool_result`.
|
||||
/// Omitted on write when empty.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
tool_call_id: String,
|
||||
/// Which emitter wrote this row. Shell (default) is omitted on the wire
|
||||
/// and is what package joins should use; workspace rows time the
|
||||
/// hub/proxy hop for the same call.
|
||||
#[serde(skip_serializing_if = "ToolCompletedSource::is_shell")]
|
||||
source: ToolCompletedSource,
|
||||
},
|
||||
PermissionRequested {
|
||||
tool_name: String,
|
||||
|
|
@ -457,6 +467,26 @@ pub enum Event {
|
|||
},
|
||||
}
|
||||
|
||||
/// Who emitted a [`Event::ToolCompleted`] row.
|
||||
///
|
||||
/// Wire: shell is omitted (legacy empty/`source` absent); workspace is
|
||||
/// `"workspace"`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCompletedSource {
|
||||
/// Shell dispatch clock — join against these.
|
||||
#[default]
|
||||
Shell,
|
||||
/// Workspace hub/proxy hop clock.
|
||||
Workspace,
|
||||
}
|
||||
|
||||
impl ToolCompletedSource {
|
||||
pub fn is_shell(&self) -> bool {
|
||||
matches!(self, Self::Shell)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a mid-turn interjection originated. Drives the `source` field on
|
||||
/// [`Event::Interjected`].
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
|
|
@ -654,6 +684,29 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_completed_source_omits_shell_writes_workspace() {
|
||||
let shell = serde_json::to_value(Event::ToolCompleted {
|
||||
tool_name: "bash".into(),
|
||||
duration_ms: 10,
|
||||
outcome: ToolOutcome::Success,
|
||||
tool_call_id: "c1".into(),
|
||||
source: ToolCompletedSource::Shell,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(shell.get("source").is_none());
|
||||
|
||||
let workspace = serde_json::to_value(Event::ToolCompleted {
|
||||
tool_name: "bash".into(),
|
||||
duration_ms: 10,
|
||||
outcome: ToolOutcome::Success,
|
||||
tool_call_id: "c1".into(),
|
||||
source: ToolCompletedSource::Workspace,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(workspace["source"], "workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjected_event_serializes_tag_source_and_count() {
|
||||
let ev = Event::Interjected {
|
||||
|
|
|
|||
|
|
@ -793,7 +793,7 @@ pub struct AgentDefinition {
|
|||
pub isolation: Option<IsolationMode>,
|
||||
#[serde(default)]
|
||||
pub background: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_agent_color")]
|
||||
pub color: Option<AgentColor>,
|
||||
#[serde(default)]
|
||||
pub initial_prompt: Option<String>,
|
||||
|
|
@ -1060,11 +1060,13 @@ const _: () =
|
|||
Eq,
|
||||
Deserialize,
|
||||
serde::Serialize,
|
||||
AsRefStr,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
strum::EnumCount,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
pub enum AgentColor {
|
||||
Red,
|
||||
Blue,
|
||||
|
|
@ -1081,6 +1083,35 @@ impl AgentColor {
|
|||
];
|
||||
}
|
||||
const _: () = assert!(AgentColor::VALID_VALUES.len() == <AgentColor as strum::EnumCount>::COUNT);
|
||||
/// Never fails: `color` is decorative, but a rejected value fails the whole
|
||||
/// frontmatter parse, and discovery skips agents that fail to parse — so a
|
||||
/// typo'd or hex color would silently make the agent unspawnable.
|
||||
///
|
||||
/// Frontmatter is only ever decoded by `serde_yaml`, so the intermediate value
|
||||
/// is captured as `serde_yaml::Value` (total for YAML — tagged scalars and
|
||||
/// maps with non-string keys included, which have no `serde_json::Value`
|
||||
/// form). Unrecognized values are dropped to `None` with a warning rather
|
||||
/// than mapped to a stand-in color the author never wrote.
|
||||
fn deserialize_agent_color<'de, D>(deserializer: D) -> Result<Option<AgentColor>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use std::str::FromStr;
|
||||
let Some(value) = Option::<serde_yaml::Value>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed = value
|
||||
.as_str()
|
||||
.and_then(|name| AgentColor::from_str(name.trim()).ok());
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(
|
||||
color = ?value,
|
||||
valid = ?AgentColor::VALID_VALUES,
|
||||
"unrecognized agent color, ignoring"
|
||||
);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
/// Agent memory scope. Distinct from `storage::MemoryScope` (global-vs-workspace write target).
|
||||
#[derive(
|
||||
Debug,
|
||||
|
|
@ -2096,8 +2127,10 @@ description: Minimal agent
|
|||
}
|
||||
for color in AgentColor::VALID_VALUES {
|
||||
let c = format!("---\nname: t\ndescription: t\ncolor: {color}\n---\n");
|
||||
assert!(
|
||||
AgentDefinition::parse(&c).unwrap().color.is_some(),
|
||||
let parsed = AgentDefinition::parse(&c).unwrap().color;
|
||||
assert_eq!(
|
||||
parsed.map(<&'static str>::from),
|
||||
Some(*color),
|
||||
"color: {color}"
|
||||
);
|
||||
}
|
||||
|
|
@ -2110,6 +2143,33 @@ description: Minimal agent
|
|||
}
|
||||
}
|
||||
#[test]
|
||||
fn unparseable_color_is_dropped_instead_of_dropping_the_agent() {
|
||||
for (declared, expected) in [
|
||||
("Purple", Some(AgentColor::Purple)),
|
||||
(" CYAN ", Some(AgentColor::Cyan)),
|
||||
("teal", None),
|
||||
("\"#ff0000\"", None),
|
||||
("chartreuse", None),
|
||||
("42", None),
|
||||
("[red, blue]", None),
|
||||
("!custom x", None),
|
||||
("{1: 2}", None),
|
||||
] {
|
||||
let c = format!("---\nname: t\ndescription: t\ncolor: {declared}\n---\n");
|
||||
let def = AgentDefinition::parse(&c)
|
||||
.unwrap_or_else(|e| panic!("color {declared} must not fail the parse: {e}"));
|
||||
assert_eq!(def.color, expected, "color: {declared}");
|
||||
assert_eq!(def.name, "t");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn absent_or_null_color_stays_none() {
|
||||
let def = AgentDefinition::parse("---\nname: t\ndescription: t\n---\n").unwrap();
|
||||
assert!(def.color.is_none());
|
||||
let def = AgentDefinition::parse("---\nname: t\ndescription: t\ncolor:\n---\n").unwrap();
|
||||
assert!(def.color.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn test_parse_missing_name() {
|
||||
let content = r#"---
|
||||
description: No name
|
||||
|
|
|
|||
|
|
@ -399,14 +399,9 @@ fn all_subagents_with_plugins_and_home(
|
|||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
// Use frontmatter-only parsing for untrusted plugins
|
||||
let def = if plugin.trusted {
|
||||
AgentDefinition::from_file(&path).ok()
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only(&path).ok()
|
||||
let Some(def) = load_plugin_agent_definition(plugin, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut def) = def else { continue };
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
|
||||
let qualified_name = format!("{}:{}", plugin.name, def.name);
|
||||
|
||||
|
|
@ -481,20 +476,14 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
{
|
||||
for agent_dir in &plugin.agent_dirs {
|
||||
let agent_file = agent_dir.join(format!("{agent_name}.md"));
|
||||
if agent_file.is_file() {
|
||||
let load_fn = if plugin.trusted {
|
||||
AgentDefinition::from_file
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only
|
||||
};
|
||||
if let Ok(mut def) = load_fn(&agent_file) {
|
||||
def.plugin_name = Some(plugin_name.to_string());
|
||||
if agent_file.is_file()
|
||||
&& let Some(mut def) = load_plugin_agent_definition(plugin, &agent_file)
|
||||
{
|
||||
substitute_plugin_vars(&mut def, plugin);
|
||||
return Some(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bare name lookup: only resolve if exactly one plugin has this agent.
|
||||
// Ambiguous matches (multiple plugins with same agent name) are rejected.
|
||||
|
|
@ -510,13 +499,7 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
}
|
||||
if matches.len() == 1 {
|
||||
let (plugin, agent_file) = &matches[0];
|
||||
let load_fn = if plugin.trusted {
|
||||
AgentDefinition::from_file
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only
|
||||
};
|
||||
if let Ok(mut def) = load_fn(agent_file) {
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
if let Some(mut def) = load_plugin_agent_definition(plugin, agent_file) {
|
||||
substitute_plugin_vars(&mut def, plugin);
|
||||
return Some(def);
|
||||
}
|
||||
|
|
@ -533,6 +516,37 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
None
|
||||
}
|
||||
|
||||
/// Load one plugin-provided agent file, tagged with its owning plugin.
|
||||
///
|
||||
/// Untrusted plugins are parsed frontmatter-only so their prompt body never
|
||||
/// reaches the model before the plugin is trusted. A parse failure drops the
|
||||
/// agent from discovery entirely, so it is logged rather than swallowed.
|
||||
fn load_plugin_agent_definition(
|
||||
plugin: &crate::plugins::LoadedPlugin,
|
||||
path: &Path,
|
||||
) -> Option<AgentDefinition> {
|
||||
let loaded = if plugin.trusted {
|
||||
AgentDefinition::from_file(path)
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only(path)
|
||||
};
|
||||
match loaded {
|
||||
Ok(mut def) => {
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
Some(def)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
plugin = %plugin.name,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to parse plugin agent definition, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` (and the Grok
|
||||
/// aliases) in a plugin agent's body so the model receives absolute paths,
|
||||
/// matching the expected load-time resolution for these variables.
|
||||
|
|
@ -1368,6 +1382,44 @@ mod tests {
|
|||
assert!(entries.iter().any(|e| e.name == "plugin-one:reviewer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_agent_with_unrecognized_color_is_still_discovered() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = tmp.path().join("workspace");
|
||||
let home = tmp.path().join("home");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
|
||||
let plugin_root = tempfile::tempdir().unwrap();
|
||||
let plugin_agents = plugin_root.path().join("agents");
|
||||
fs::create_dir_all(&plugin_agents).unwrap();
|
||||
fs::write(
|
||||
plugin_agents.join("painter.md"),
|
||||
"---\nname: painter\ndescription: Plugin painter\ncolor: chartreuse\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = make_plugin_registry("plugin-one", PluginScope::User, vec![plugin_agents]);
|
||||
let entries = all_subagents_with_plugins_and_home(
|
||||
&cwd,
|
||||
&HashMap::new(),
|
||||
Some(®istry),
|
||||
Some(&home),
|
||||
Some(&home.join(".grok")),
|
||||
);
|
||||
assert!(entries.iter().any(|e| e.name == "plugin-one:painter"));
|
||||
|
||||
let def = by_name_in_cwd_with_plugins_and_home(
|
||||
"plugin-one:painter",
|
||||
&cwd,
|
||||
Some(®istry),
|
||||
Some(&home),
|
||||
Some(&home.join(".grok")),
|
||||
)
|
||||
.expect("agent must resolve despite the unrecognized color");
|
||||
assert_eq!(def.color, None, "unrecognized color must be dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_by_name_in_cwd_with_plugins_prefers_native_over_plugin_bare_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ pub mod visibility;
|
|||
|
||||
pub use auth_provider::{AuthCredentialProvider, CredentialSnapshot, StaticAuthCredentialProvider};
|
||||
#[cfg(feature = "middleware")]
|
||||
pub use retry_middleware::AuthRetryMiddleware;
|
||||
pub use retry_middleware::{AuthRetryMiddleware, StampedBearerSuffix, execute_with_stamp};
|
||||
pub use visibility::HttpAuth;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,47 @@ use reqwest_middleware::{Error, Middleware, Next};
|
|||
|
||||
use crate::AuthCredentialProvider;
|
||||
|
||||
/// Tail fragment (last [`STAMPED_BEARER_SUFFIX_LEN`] chars) of the bearer
|
||||
/// this middleware stamped, recorded into the request's `http::Extensions`
|
||||
/// at stamp time. 401-attribution sites read it back via
|
||||
/// [`execute_with_stamp`] instead of re-resolving at record time, which
|
||||
/// races with the refresh the 401 itself triggers. Absent ⇒ nothing was
|
||||
/// stamped; a retry overwrites it, so it always describes the attempt whose
|
||||
/// response the caller holds. Only the tail crosses this boundary — JWT
|
||||
/// heads are a shared constant, and the tail is safe for sinks to log.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StampedBearerSuffix(pub String);
|
||||
|
||||
/// Length of [`StampedBearerSuffix`]. Matches `token_suffix` in
|
||||
/// xai-grok-shell (the comparison site for 401 attribution).
|
||||
const STAMPED_BEARER_SUFFIX_LEN: usize = 12;
|
||||
|
||||
/// Last [`STAMPED_BEARER_SUFFIX_LEN`] chars, counting chars from the end
|
||||
/// so a non-ASCII credential cannot cause a byte-boundary panic.
|
||||
fn bearer_suffix(token: &str) -> &str {
|
||||
match token
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(STAMPED_BEARER_SUFFIX_LEN - 1)
|
||||
{
|
||||
Some((i, _)) => &token[i..],
|
||||
None => token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute `req` on a middleware-wrapped client and return the response
|
||||
/// together with the [`StampedBearerSuffix`] the auth middleware recorded
|
||||
/// (if it stamped anything). The one blessed way for 401-attribution
|
||||
/// call sites to learn what was actually sent on the wire.
|
||||
pub async fn execute_with_stamp(
|
||||
client: &reqwest_middleware::ClientWithMiddleware,
|
||||
req: Request,
|
||||
) -> reqwest_middleware::Result<(Response, Option<StampedBearerSuffix>)> {
|
||||
let mut ext = http::Extensions::new();
|
||||
let resp = client.execute_with_extensions(req, &mut ext).await?;
|
||||
Ok((resp, ext.get::<StampedBearerSuffix>().cloned()))
|
||||
}
|
||||
|
||||
pub struct AuthRetryMiddleware {
|
||||
credentials: Arc<dyn AuthCredentialProvider>,
|
||||
max_retries: u32,
|
||||
|
|
@ -22,11 +63,12 @@ impl AuthRetryMiddleware {
|
|||
}
|
||||
}
|
||||
|
||||
fn apply_auth_header(req: &mut Request, token: &str) {
|
||||
fn apply_auth_header(req: &mut Request, token: &str, extensions: &mut http::Extensions) {
|
||||
match HeaderValue::from_str(&format!("Bearer {token}")) {
|
||||
Ok(val) => {
|
||||
req.headers_mut()
|
||||
.insert(reqwest::header::AUTHORIZATION, val);
|
||||
extensions.insert(StampedBearerSuffix(bearer_suffix(token).to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "auth retry: failed to build Authorization header");
|
||||
|
|
@ -43,7 +85,7 @@ impl Middleware for AuthRetryMiddleware {
|
|||
next: Next<'_>,
|
||||
) -> Result<Response, Error> {
|
||||
if let Some(ref token) = self.credentials.snapshot().token {
|
||||
apply_auth_header(&mut req, token);
|
||||
apply_auth_header(&mut req, token, extensions);
|
||||
}
|
||||
|
||||
let backup = req.try_clone();
|
||||
|
|
@ -67,7 +109,7 @@ impl Middleware for AuthRetryMiddleware {
|
|||
let Some(mut retry) = backup.try_clone() else {
|
||||
break;
|
||||
};
|
||||
apply_auth_header(&mut retry, token);
|
||||
apply_auth_header(&mut retry, token, extensions);
|
||||
last_resp = next.clone().run(retry, extensions).await?;
|
||||
if last_resp.status() != StatusCode::UNAUTHORIZED {
|
||||
return Ok(last_resp);
|
||||
|
|
@ -251,6 +293,73 @@ mod tests {
|
|||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// The stamp must describe the bearer of the attempt whose response
|
||||
/// the caller holds: after a 401 → refresh → retry, that is the
|
||||
/// FRESH token, not the stale one stamped on the first attempt.
|
||||
#[tokio::test]
|
||||
async fn execute_with_stamp_reports_last_stamped_bearer() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m401 = server
|
||||
.mock("GET", "/api")
|
||||
.match_header("authorization", "Bearer stale-token")
|
||||
.with_status(401)
|
||||
.create_async()
|
||||
.await;
|
||||
let m200 = server
|
||||
.mock("GET", "/api")
|
||||
.match_header("authorization", "Bearer fresh-token")
|
||||
.with_status(200)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let p = Arc::new(SimulatedAuthManager::simulated(
|
||||
"stale-token",
|
||||
"fresh-token",
|
||||
));
|
||||
let client = build_client(p, 1).await;
|
||||
|
||||
let req = client.get(format!("{}/api", server.url())).build().unwrap();
|
||||
let (resp, stamp) = execute_with_stamp(&client, req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
// ≤ 12 chars → the suffix is the whole token.
|
||||
assert_eq!(stamp.expect("bearer was stamped").0, "fresh-token");
|
||||
m401.assert_async().await;
|
||||
m200.assert_async().await;
|
||||
}
|
||||
|
||||
/// No credential ⇒ no stamp: attribution must see "nothing was sent",
|
||||
/// not an empty string or a stale record.
|
||||
#[tokio::test]
|
||||
async fn execute_with_stamp_is_none_when_nothing_stamped() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(401)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let p = Arc::new(MockProvider::new(None, false));
|
||||
let client = build_client(p, 0).await;
|
||||
|
||||
let req = client.get(server.url()).build().unwrap();
|
||||
let (resp, stamp) = execute_with_stamp(&client, req).await.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(stamp.is_none(), "no credential must mean no stamp");
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_suffix_takes_char_safe_tail() {
|
||||
assert_eq!(
|
||||
bearer_suffix("eyJ0eXAiOiJh.head.tail-distinct"),
|
||||
"ail-distinct"
|
||||
);
|
||||
assert_eq!(bearer_suffix("short"), "short");
|
||||
assert_eq!(bearer_suffix(""), "");
|
||||
// 13 multi-byte chars: a byte-index cut would land mid-char.
|
||||
assert_eq!(bearer_suffix("ééééééééééééé"), "éééééééééééé");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max_retries_bounds_attempts() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ pub struct WorktreeAutoGcSettings {
|
|||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub max_age_by_kind: Option<std::collections::BTreeMap<String, WorktreeKindMaxAge>>,
|
||||
/// Optional discovery rebuild + stale `.git/worktrees/` prune (default off).
|
||||
/// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off).
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "de_opt_bool_tolerant",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ fn validate_with_ops(
|
|||
.stderr(Stdio::null())
|
||||
.envs(xai_tty_utils::pager_env());
|
||||
xai_tty_utils::detach_std_command(&mut command);
|
||||
#[allow(clippy::disallowed_methods)] // config validator, waited on with a timeout
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| ManagedConfigError::Validation {
|
||||
|
|
|
|||
20
crates/codegen/xai-grok-extra-ca/Cargo.toml
Normal file
20
crates/codegen/xai-grok-extra-ca/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-extra-ca"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Opt-in extra TLS roots from GROK_EXTRA_CA_BUNDLE (validated DER cache + reqwest 0.12 adapters)"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
# Explicit pin (not `workspace = true`): the workspace rustls pin enables
|
||||
# aws-lc-rs. This crate only needs RootCertStore + PEM parse, so stay
|
||||
# default-features = false with `std` (enables pki-types/std for PEM).
|
||||
rustls = { version = "0.23", default-features = false, features = ["std"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
184
crates/codegen/xai-grok-extra-ca/src/lib.rs
Normal file
184
crates/codegen/xai-grok-extra-ca/src/lib.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Opt-in extra TLS roots via `GROK_EXTRA_CA_BUNDLE` (PEM path).
|
||||
//!
|
||||
//! Default-off (unset/empty env → no I/O); parsed once into a process
|
||||
//! `OnceLock`; additive to webpki roots. Each DER is validated with
|
||||
//! `rustls::RootCertStore::add` before caching so a bad bundle cannot fail
|
||||
//! `ClientBuilder::build()`. Unreadable/oversized/empty/unparsable → warn and
|
||||
//! continue. Size cap: [`MAX_EXTRA_CA_BUNDLE_BYTES`].
|
||||
//!
|
||||
//! Source of truth is validated DER ([`extra_root_ders`]) so reqwest 0.12
|
||||
//! (this crate's adapters) and MCP's 0.13 can each build their own
|
||||
//! `Certificate`s.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use rustls::RootCertStore;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
|
||||
/// Hard cap on `GROK_EXTRA_CA_BUNDLE` (1 MiB) — avoids unbounded startup reads.
|
||||
pub const MAX_EXTRA_CA_BUNDLE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Env var name for the opt-in extra CA bundle (PEM path).
|
||||
pub const ENV_GROK_EXTRA_CA_BUNDLE: &str = "GROK_EXTRA_CA_BUNDLE";
|
||||
|
||||
/// Process-wide extra roots as validated DER, parsed once.
|
||||
///
|
||||
/// Empty when the env var is unset/empty or the file yields no usable certs.
|
||||
pub fn extra_root_ders() -> &'static [Vec<u8>] {
|
||||
static DERS: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
|
||||
DERS.get_or_init(load_extra_root_ders).as_slice()
|
||||
}
|
||||
|
||||
/// Apply [`extra_root_ders`] to a workspace (reqwest 0.12) async `ClientBuilder`.
|
||||
pub fn with_extra_root_certificates(mut builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
|
||||
for der in extra_root_ders() {
|
||||
match reqwest::Certificate::from_der(der) {
|
||||
Ok(cert) => builder = builder.add_root_certificate(cert),
|
||||
// WHY: rustls already accepted this DER; skip rather than poison build.
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest; skipping cert"
|
||||
),
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Apply [`extra_root_ders`] to a workspace (reqwest 0.12) blocking `ClientBuilder`.
|
||||
pub fn with_extra_root_certificates_blocking(
|
||||
mut builder: reqwest::blocking::ClientBuilder,
|
||||
) -> reqwest::blocking::ClientBuilder {
|
||||
for der in extra_root_ders() {
|
||||
match reqwest::Certificate::from_der(der) {
|
||||
Ok(cert) => builder = builder.add_root_certificate(cert),
|
||||
// WHY: rustls already accepted this DER; skip rather than poison build.
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest; skipping cert"
|
||||
),
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn load_extra_root_ders() -> Vec<Vec<u8>> {
|
||||
let path = match std::env::var_os(ENV_GROK_EXTRA_CA_BUNDLE) {
|
||||
Some(p) if !p.is_empty() => std::path::PathBuf::from(p),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
let bytes = match read_bundle_capped(&path) {
|
||||
Ok(b) => b,
|
||||
Err(BundleReadError::Io(e)) => {
|
||||
// WHY: MITM CA is optional; a missing path must not brick HTTP.
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"GROK_EXTRA_CA_BUNDLE unreadable; continuing without extra roots"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
Err(BundleReadError::TooLarge) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
max_bytes = MAX_EXTRA_CA_BUNDLE_BYTES,
|
||||
"GROK_EXTRA_CA_BUNDLE exceeds size cap; continuing without extra roots"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = parse_and_validate_pem(&bytes);
|
||||
if outcome.no_pem_blocks {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"GROK_EXTRA_CA_BUNDLE contains no PEM certificate blocks; continuing without extra roots"
|
||||
);
|
||||
return outcome.accepted;
|
||||
}
|
||||
if outcome.rejected > 0 {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
accepted = outcome.accepted.len(),
|
||||
rejected = outcome.rejected,
|
||||
"GROK_EXTRA_CA_BUNDLE: dropped unusable certificate block(s)"
|
||||
);
|
||||
}
|
||||
if outcome.accepted.is_empty() {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"GROK_EXTRA_CA_BUNDLE produced zero usable certificates; continuing without extra roots"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
accepted = outcome.accepted.len(),
|
||||
"GROK_EXTRA_CA_BUNDLE: loaded extra root certificate(s)"
|
||||
);
|
||||
}
|
||||
outcome.accepted
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum BundleReadError {
|
||||
Io(std::io::Error),
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn read_bundle_capped(path: &std::path::Path) -> Result<Vec<u8>, BundleReadError> {
|
||||
let file = std::fs::File::open(path).map_err(BundleReadError::Io)?;
|
||||
let mut buf = Vec::new();
|
||||
let n = file
|
||||
.take(MAX_EXTRA_CA_BUNDLE_BYTES + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(BundleReadError::Io)?;
|
||||
if (n as u64) > MAX_EXTRA_CA_BUNDLE_BYTES {
|
||||
return Err(BundleReadError::TooLarge);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Result of parsing a PEM bundle into rustls-validated DER roots.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ParseOutcome {
|
||||
pub(crate) accepted: Vec<Vec<u8>>,
|
||||
/// PEM blocks that failed decode or rustls X.509 validation.
|
||||
pub(crate) rejected: usize,
|
||||
/// Input (non-empty) contained no PEM certificate blocks at all.
|
||||
pub(crate) no_pem_blocks: bool,
|
||||
}
|
||||
|
||||
/// Parse PEM into rustls-validated DER (no env / OnceLock). Input with no PEM
|
||||
/// certificate blocks (including empty) → empty accepted, zero rejected,
|
||||
/// `no_pem_blocks` set.
|
||||
pub(crate) fn parse_and_validate_pem(pem: &[u8]) -> ParseOutcome {
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = 0usize;
|
||||
let mut saw_block = false;
|
||||
|
||||
// WHY: reject non-X.509 DER before any ClientBuilder sees it; `add`
|
||||
// validates per certificate, so one store serves the whole bundle.
|
||||
let mut store = RootCertStore::empty();
|
||||
for item in CertificateDer::pem_slice_iter(pem) {
|
||||
saw_block = true;
|
||||
match item {
|
||||
Ok(der) => match store.add(der.clone()) {
|
||||
Ok(()) => accepted.push(der.as_ref().to_vec()),
|
||||
Err(_) => rejected += 1,
|
||||
},
|
||||
Err(_) => rejected += 1,
|
||||
}
|
||||
}
|
||||
|
||||
ParseOutcome {
|
||||
accepted,
|
||||
rejected,
|
||||
no_pem_blocks: !saw_block,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
130
crates/codegen/xai-grok-extra-ca/src/lib_tests.rs
Normal file
130
crates/codegen/xai-grok-extra-ca/src/lib_tests.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use super::*;
|
||||
|
||||
// Self-signed PEMs for unit tests only (CN=test-extra-ca-1 / -2).
|
||||
const VALID_CERT_1: &str = "-----BEGIN CERTIFICATE-----\n\
|
||||
MIIDFTCCAf2gAwIBAgIUT2czXTuxSAjDjEh92UMB1OVahZYwDQYJKoZIhvcNAQEL\n\
|
||||
BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMB4XDTI2MDcyOTE4MzUwNFoX\n\
|
||||
DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMIIBIjAN\n\
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gNk2BQwUy+n5cCaTFtGpSzVQv//\n\
|
||||
d7QD+3QWeE411wIGJzp3nrd7np55X8JHxeg/pRhspQvLQAF7bt55LSkL/+sSth3S\n\
|
||||
QTbBqhftic9CXik3llAwbdQkAM9srz5zXWW9KVjZ57dxjjxrS15SCXu/UmvGZy98\n\
|
||||
faJcS++TRkczsNFzwQEqeDYARVc/no0C0I++NhGLPaNMfFAevvnu6Kt3CYMI5ls4\n\
|
||||
KCFgnlau4CjgRCMSfRDCRcwEwUAp+DyX9IU+tvDAQY1ncVoa/05tvaEvw7pQ+UgW\n\
|
||||
0wRG0lk7PLlcWmUkLcFpO+sL5GRkC8RoWM4cFbIOiXoVxUFks/z2y0GCEQIDAQAB\n\
|
||||
o1MwUTAdBgNVHQ4EFgQU+lyC70W5aR6BIf4VNtjfiWMNzzkwHwYDVR0jBBgwFoAU\n\
|
||||
+lyC70W5aR6BIf4VNtjfiWMNzzkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\
|
||||
AQsFAAOCAQEA02972nA7LshRgubz6BwXbh1gA5pLzTd5KEae+94Hq6mP2zJ1T0gk\n\
|
||||
x+me0NtSgG4BJLdBIylUzo2UmsfB/sz+ght6WX1uB38Vc2UQsp0sRPeeiMovSd6n\n\
|
||||
I7xZyuZEF3noYJVBBlKQ8XsCUIBNIROlyKlNjNcWY8tGqPh9cepvtZYkBgRZr1vW\n\
|
||||
hJAE3EOL2ZddrMPF64QeU9UhvCm0Ch+Ceqa1ZWE0MygccggX5s2yQwtXO2ovJdjH\n\
|
||||
6vW0I02r8sE+NX0d1u8rIPJEKlp89UwCwniD7SxHTNw8bbsTCWz+AMod7vC7De3X\n\
|
||||
4Daxme+vD8adOfCeOIu5vNrlXLNST2yaTw==\n\
|
||||
-----END CERTIFICATE-----\n";
|
||||
|
||||
const VALID_CERT_2: &str = "-----BEGIN CERTIFICATE-----\n\
|
||||
MIIDFTCCAf2gAwIBAgIUKckMakNVssdBbRUlVtyWZZPx7EcwDQYJKoZIhvcNAQEL\n\
|
||||
BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0yMB4XDTI2MDcyOTE4MzUwNFoX\n\
|
||||
DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0yMIIBIjAN\n\
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3pVKr4xNdWm+RIYVRuOv+8Pg3I3/\n\
|
||||
wsmC7m84I4bw6EofraYY1vTT8XYcWAspo++Tj1hYNAyfdtdrgdZT8dgsTqsVPzYz\n\
|
||||
rluGu03mu0aE9Ix2IieLvR9C0s+mYpsfCQYRjsL2wDD6fOAWN4wjj1R4XGgUZKCF\n\
|
||||
q8JirftcRBLGjAa8XXD496dUGXzURQ7C9jAxFmPWGbyz3f1ymOLBvp8RdzrJNCsA\n\
|
||||
zdEjqJODMMf0czJH5gtt06hIQG9JkPHNqZXVxEIBIDlkmkr9Nk/asqZGhbHILkHX\n\
|
||||
/jqfdOMb4Xu95iglbwbACgAtfysNQdjUU7hbjKxx4S4FCjf+gyb4whQo/QIDAQAB\n\
|
||||
o1MwUTAdBgNVHQ4EFgQUVrqEwVrKpoc/GinOYZR13TkjdwgwHwYDVR0jBBgwFoAU\n\
|
||||
VrqEwVrKpoc/GinOYZR13TkjdwgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\
|
||||
AQsFAAOCAQEAtK9ylmMIEQsuYm5Qo1pi4xp5rFywO0g5zkWEl/fIMBevP9Thhnco\n\
|
||||
gHiOFBhQcuo+Go65p3Fbbt3Vrx30Oi0hQUlYLlY44BO3/TgfZ0VbIheeDfyYaq97\n\
|
||||
S3I1cLHJ1qmKq99zKcqvCcD+NmifbuMi03Zo35Kp+jm8GXpONumnPlu17WZLw5N7\n\
|
||||
KFHbC1eO3iat27z4WRhPHG4vmPfMHIIvrbA+aEwc1b88NO5UdRmSHvkt4MDEOsIe\n\
|
||||
IgKmdcW5+BG5ffCRJ9wNsCCy165AFUmuNWz0aqDWybjK4eiEb88sHKbVv7fyXpwi\n\
|
||||
YwiFroodmakt1behpPy1p9Ih94MTqy9pQw==\n\
|
||||
-----END CERTIFICATE-----\n";
|
||||
|
||||
/// Valid PEM framing / base64, but DER is not an X.509 certificate.
|
||||
const INVALID_DER_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
|
||||
MAMBAf8=\n\
|
||||
-----END CERTIFICATE-----\n";
|
||||
|
||||
#[test]
|
||||
fn parse_empty_bytes_returns_empty() {
|
||||
let o = parse_and_validate_pem(b"");
|
||||
assert!(o.accepted.is_empty());
|
||||
assert_eq!(o.rejected, 0);
|
||||
assert!(o.no_pem_blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_garbage_non_pem_flags_no_blocks_without_panic() {
|
||||
let o = parse_and_validate_pem(b"this is not a certificate");
|
||||
assert!(o.accepted.is_empty());
|
||||
assert_eq!(o.rejected, 0);
|
||||
assert!(o.no_pem_blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_single_cert_pem() {
|
||||
let o = parse_and_validate_pem(VALID_CERT_1.as_bytes());
|
||||
assert_eq!(o.accepted.len(), 1);
|
||||
assert_eq!(o.rejected, 0);
|
||||
assert!(!o.no_pem_blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multi_cert_bundle() {
|
||||
let o = parse_and_validate_pem(format!("{VALID_CERT_1}\n{VALID_CERT_2}").as_bytes());
|
||||
assert_eq!(o.accepted.len(), 2);
|
||||
assert_eq!(o.rejected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_der_pem_rejected() {
|
||||
let o = parse_and_validate_pem(INVALID_DER_PEM.as_bytes());
|
||||
assert!(o.accepted.is_empty());
|
||||
assert!(o.rejected >= 1);
|
||||
assert!(!o.no_pem_blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mixed_bundle_keeps_valid_drops_invalid() {
|
||||
let o = parse_and_validate_pem(
|
||||
format!("{VALID_CERT_1}\n{INVALID_DER_PEM}\n{VALID_CERT_2}").as_bytes(),
|
||||
);
|
||||
assert_eq!(o.accepted.len(), 2);
|
||||
assert!(o.rejected >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_ders_build_reqwest_client() {
|
||||
let o = parse_and_validate_pem(VALID_CERT_1.as_bytes());
|
||||
assert_eq!(o.accepted.len(), 1);
|
||||
let mut builder = reqwest::Client::builder();
|
||||
for der in &o.accepted {
|
||||
builder = builder.add_root_certificate(
|
||||
reqwest::Certificate::from_der(der).expect("from_der after rustls validation"),
|
||||
);
|
||||
}
|
||||
builder
|
||||
.build()
|
||||
.expect("client with validated roots must construct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bundle_capped_rejects_oversized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("huge.pem");
|
||||
std::fs::write(&path, vec![b'A'; (MAX_EXTRA_CA_BUNDLE_BYTES as usize) + 1]).unwrap();
|
||||
match read_bundle_capped(&path) {
|
||||
Err(BundleReadError::TooLarge) => {}
|
||||
other => panic!("expected TooLarge, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bundle_capped_accepts_at_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ok.pem");
|
||||
std::fs::write(&path, vec![b'B'; MAX_EXTRA_CA_BUNDLE_BYTES as usize]).unwrap();
|
||||
let got = read_bundle_capped(&path).expect("at-limit read");
|
||||
assert_eq!(got.len(), MAX_EXTRA_CA_BUNDLE_BYTES as usize);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! Process-isolated: missing GROK_EXTRA_CA_BUNDLE path → fail-open client build.
|
||||
|
||||
#[test]
|
||||
fn missing_bundle_path_builds_clients_without_panic() {
|
||||
// Safety: sole test in this binary; set before any OnceLock resolve.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE,
|
||||
"/nonexistent/grok-extra-ca-bundle-invalid-file.pem",
|
||||
);
|
||||
}
|
||||
|
||||
assert!(xai_grok_extra_ca::extra_root_ders().is_empty());
|
||||
|
||||
xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder())
|
||||
.build()
|
||||
.expect("async client builds when bundle is unreadable");
|
||||
|
||||
xai_grok_extra_ca::with_extra_root_certificates_blocking(reqwest::blocking::Client::builder())
|
||||
.build()
|
||||
.expect("blocking client builds when bundle is unreadable");
|
||||
}
|
||||
34
crates/codegen/xai-grok-extra-ca/tests/extra_ca_oversized.rs
Normal file
34
crates/codegen/xai-grok-extra-ca/tests/extra_ca_oversized.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! Process-isolated: oversize GROK_EXTRA_CA_BUNDLE → ignored; client still builds.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn oversized_bundle_ignored_clients_build() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("oversized.pem");
|
||||
{
|
||||
let mut f = std::fs::File::create(&path).expect("create");
|
||||
let chunk = vec![b'X'; 64 * 1024];
|
||||
let mut written = 0u64;
|
||||
let target = xai_grok_extra_ca::MAX_EXTRA_CA_BUNDLE_BYTES + 1;
|
||||
while written < target {
|
||||
let n = ((target - written) as usize).min(chunk.len());
|
||||
f.write_all(&chunk[..n]).expect("write");
|
||||
written += n as u64;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: sole test in this binary; set before any OnceLock resolve.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE,
|
||||
path.as_os_str(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(xai_grok_extra_ca::extra_root_ders().is_empty());
|
||||
|
||||
xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder())
|
||||
.build()
|
||||
.expect("client builds after oversized reject");
|
||||
}
|
||||
41
crates/codegen/xai-grok-extra-ca/tests/extra_ca_valid_env.rs
Normal file
41
crates/codegen/xai-grok-extra-ca/tests/extra_ca_valid_env.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! Process-isolated: valid GROK_EXTRA_CA_BUNDLE loads one root via OnceLock.
|
||||
|
||||
#[test]
|
||||
fn valid_bundle_loads_one_root() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("ca.pem");
|
||||
const CERT: &str = "-----BEGIN CERTIFICATE-----\n\
|
||||
MIIDFTCCAf2gAwIBAgIUT2czXTuxSAjDjEh92UMB1OVahZYwDQYJKoZIhvcNAQEL\n\
|
||||
BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMB4XDTI2MDcyOTE4MzUwNFoX\n\
|
||||
DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMIIBIjAN\n\
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gNk2BQwUy+n5cCaTFtGpSzVQv//\n\
|
||||
d7QD+3QWeE411wIGJzp3nrd7np55X8JHxeg/pRhspQvLQAF7bt55LSkL/+sSth3S\n\
|
||||
QTbBqhftic9CXik3llAwbdQkAM9srz5zXWW9KVjZ57dxjjxrS15SCXu/UmvGZy98\n\
|
||||
faJcS++TRkczsNFzwQEqeDYARVc/no0C0I++NhGLPaNMfFAevvnu6Kt3CYMI5ls4\n\
|
||||
KCFgnlau4CjgRCMSfRDCRcwEwUAp+DyX9IU+tvDAQY1ncVoa/05tvaEvw7pQ+UgW\n\
|
||||
0wRG0lk7PLlcWmUkLcFpO+sL5GRkC8RoWM4cFbIOiXoVxUFks/z2y0GCEQIDAQAB\n\
|
||||
o1MwUTAdBgNVHQ4EFgQU+lyC70W5aR6BIf4VNtjfiWMNzzkwHwYDVR0jBBgwFoAU\n\
|
||||
+lyC70W5aR6BIf4VNtjfiWMNzzkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\
|
||||
AQsFAAOCAQEA02972nA7LshRgubz6BwXbh1gA5pLzTd5KEae+94Hq6mP2zJ1T0gk\n\
|
||||
x+me0NtSgG4BJLdBIylUzo2UmsfB/sz+ght6WX1uB38Vc2UQsp0sRPeeiMovSd6n\n\
|
||||
I7xZyuZEF3noYJVBBlKQ8XsCUIBNIROlyKlNjNcWY8tGqPh9cepvtZYkBgRZr1vW\n\
|
||||
hJAE3EOL2ZddrMPF64QeU9UhvCm0Ch+Ceqa1ZWE0MygccggX5s2yQwtXO2ovJdjH\n\
|
||||
6vW0I02r8sE+NX0d1u8rIPJEKlp89UwCwniD7SxHTNw8bbsTCWz+AMod7vC7De3X\n\
|
||||
4Daxme+vD8adOfCeOIu5vNrlXLNST2yaTw==\n\
|
||||
-----END CERTIFICATE-----\n";
|
||||
std::fs::write(&path, CERT).expect("write cert");
|
||||
|
||||
// Safety: sole test in this binary; set before any OnceLock resolve.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE,
|
||||
path.as_os_str(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(xai_grok_extra_ca::extra_root_ders().len(), 1);
|
||||
|
||||
xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder())
|
||||
.build()
|
||||
.expect("client with env-loaded root builds");
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! Process-isolated: configured garbage file → zero roots; client still builds.
|
||||
|
||||
#[test]
|
||||
fn configured_garbage_file_yields_zero_roots_and_builds() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("garbage.pem");
|
||||
std::fs::write(&path, b"not a pem at all").expect("write");
|
||||
|
||||
// Safety: sole test in this binary; set before any OnceLock resolve.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE,
|
||||
path.as_os_str(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(xai_grok_extra_ca::extra_root_ders().is_empty());
|
||||
|
||||
xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder())
|
||||
.build()
|
||||
.expect("client builds after zero-cert configured file");
|
||||
}
|
||||
|
|
@ -156,6 +156,7 @@ pub async fn run_command_hook(
|
|||
// See the `runner_injected_vars_override_extra_env_at_spawn`
|
||||
// regression test in `tests/integration.rs` and the rustdoc on
|
||||
// `HookSpec::extra_env`.
|
||||
#[allow(clippy::disallowed_methods)] // enrolled in the session scope below
|
||||
let mut child = match cmd
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ reqwest-middleware = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
xai-grok-auth = { workspace = true, features = ["middleware"] }
|
||||
xai-grok-extra-ca = { workspace = true }
|
||||
xai-grok-sampler = { path = "../xai-grok-sampler" }
|
||||
xai-grok-telemetry = { workspace = true }
|
||||
xai-grok-version = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
//! a fresh client per `SamplingClient`.
|
||||
//!
|
||||
//! TLS root certificates are warmed at process start via
|
||||
//! `warm_async_http_client()` (in `mvp_agent.rs`).
|
||||
//! `warm_async_http_client()` (in `mvp_agent.rs`). Optional extra roots:
|
||||
//! `GROK_EXTRA_CA_BUNDLE` via `xai_grok_extra_ca` (see env-var registry).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -320,6 +321,7 @@ pub fn shared_client() -> reqwest::Client {
|
|||
CLIENT
|
||||
.get_or_init(|| {
|
||||
let _timer = startup_timer!("startup.http_client_build");
|
||||
xai_grok_extra_ca::with_extra_root_certificates(
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(process_user_agent_string())
|
||||
|
|
@ -327,7 +329,8 @@ pub fn shared_client() -> reqwest::Client {
|
|||
.http2_keep_alive_interval(std::time::Duration::from_secs(20))
|
||||
.http2_keep_alive_timeout(std::time::Duration::from_secs(10))
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.tcp_keepalive(std::time::Duration::from_secs(30))
|
||||
.tcp_keepalive(std::time::Duration::from_secs(30)),
|
||||
)
|
||||
.build()
|
||||
.expect("failed to build shared HTTP client")
|
||||
})
|
||||
|
|
@ -362,6 +365,7 @@ pub fn shared_upload_client() -> reqwest::Client {
|
|||
static UPLOAD_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
UPLOAD_CLIENT
|
||||
.get_or_init(|| {
|
||||
xai_grok_extra_ca::with_extra_root_certificates(
|
||||
reqwest::Client::builder()
|
||||
// Force HTTP/1.1: batch_upload multipart bodies are silently
|
||||
// dropped when an HTTP/2 connection degrades (GOAWAY, flow-control
|
||||
|
|
@ -372,7 +376,8 @@ pub fn shared_upload_client() -> reqwest::Client {
|
|||
.http1_only()
|
||||
.pool_max_idle_per_host(2)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(10))
|
||||
.user_agent(process_user_agent_string())
|
||||
.user_agent(process_user_agent_string()),
|
||||
)
|
||||
.build()
|
||||
.expect("failed to build shared upload HTTP client")
|
||||
})
|
||||
|
|
@ -387,10 +392,12 @@ pub fn shared_upload_client() -> reqwest::Client {
|
|||
/// Fallible: build can fail under fd/TLS pressure; the caller must not
|
||||
/// panic on error (fallback policy lives at the call site).
|
||||
pub(crate) fn fresh_http1_client() -> reqwest::Result<reqwest::Client> {
|
||||
xai_grok_extra_ca::with_extra_root_certificates(
|
||||
reqwest::Client::builder()
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.user_agent(process_user_agent_string())
|
||||
.user_agent(process_user_agent_string()),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
@ -543,12 +550,14 @@ pub fn shared_startup_blocking_client() -> reqwest::blocking::Client {
|
|||
BLOCKING_CLIENT
|
||||
.get_or_init(|| {
|
||||
let _timer = startup_timer!("startup.http_blocking_client_build");
|
||||
xai_grok_extra_ca::with_extra_root_certificates_blocking(
|
||||
reqwest::blocking::Client::builder()
|
||||
.connect_timeout(STARTUP_FETCH_TIMEOUT)
|
||||
.timeout(STARTUP_FETCH_TIMEOUT)
|
||||
.user_agent(process_user_agent_string())
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(30))
|
||||
.tcp_keepalive(std::time::Duration::from_secs(30))
|
||||
.tcp_keepalive(std::time::Duration::from_secs(30)),
|
||||
)
|
||||
.build()
|
||||
.expect("failed to build shared blocking HTTP client")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ rmcp = { version = "2.1", features = [
|
|||
"transport-streamable-http-client-reqwest",
|
||||
"reqwest",
|
||||
] }
|
||||
xai-grok-extra-ca = { workspace = true }
|
||||
xai-grok-version = { workspace = true }
|
||||
# reqwest 0.13 feature set for MCP transports. Notably:
|
||||
# - `blocking` is for build-script style use (not actually used here today, but
|
||||
|
|
|
|||
|
|
@ -121,22 +121,12 @@ impl McpCredentialStore {
|
|||
self.save_to(&path)
|
||||
}
|
||||
|
||||
/// Atomically insert a credential and save — safe for concurrent use.
|
||||
///
|
||||
/// Instead of the caller doing `insert_rmcp` + `save_default` (which races
|
||||
/// with other processes), this method:
|
||||
/// 1. Acquires a file lock on `mcp_credentials.json.lock`
|
||||
/// 2. Reloads the store from disk (picks up other processes' writes)
|
||||
/// 3. Inserts the new entry
|
||||
/// 4. Saves atomically (temp + rename)
|
||||
/// 5. Updates `self` with the merged result
|
||||
/// 6. Releases the lock
|
||||
pub fn insert_and_save(
|
||||
&mut self,
|
||||
server_name: &str,
|
||||
server_url: &url::Url,
|
||||
creds: rmcp::transport::auth::StoredCredentials,
|
||||
) -> Result<()> {
|
||||
/// Read-modify-write the **default** store under the cross-process
|
||||
/// `mcp_credentials.json.lock` flock: reload from disk (merging concurrent
|
||||
/// writers), apply `mutate`, save atomically, and update `self` with the
|
||||
/// merged result. On flock failure (non-EINTR error, or non-Unix), falls
|
||||
/// back to mutating `self` and saving best-effort — the pre-lock behavior.
|
||||
fn locked_mutate_and_save(&mut self, mutate: &dyn Fn(&mut Self)) -> Result<()> {
|
||||
let path = Self::default_path().ok_or_else(|| {
|
||||
McpCredentialError::Other("no user grok home (set $GROK_HOME or $HOME)".into())
|
||||
})?;
|
||||
|
|
@ -165,14 +155,14 @@ impl McpCredentialStore {
|
|||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue; // Retry on EINTR.
|
||||
}
|
||||
// Lock failed for another reason — fall back to non-atomic insert.
|
||||
self.insert_rmcp(server_name, server_url, creds);
|
||||
// Lock failed for another reason — fall back to non-atomic write.
|
||||
mutate(self);
|
||||
return self.save_to(&path);
|
||||
}
|
||||
|
||||
// Reload from disk under lock to merge with concurrent writes.
|
||||
let mut fresh = Self::load_from(&path).unwrap_or_default();
|
||||
fresh.insert_rmcp(server_name, server_url, creds);
|
||||
mutate(&mut fresh);
|
||||
fresh.save_to(&path)?;
|
||||
*self = fresh;
|
||||
|
||||
|
|
@ -182,13 +172,38 @@ impl McpCredentialStore {
|
|||
#[cfg(not(unix))]
|
||||
{
|
||||
// No flock on non-unix — best-effort.
|
||||
self.insert_rmcp(server_name, server_url, creds);
|
||||
mutate(self);
|
||||
self.save_to(&path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locked insert ([`Self::locked_mutate_and_save`]) with a freshness
|
||||
/// guard: skipped when the disk entry is strictly newer by
|
||||
/// `token_received_at` (see [`disk_entry_is_newer`]) — otherwise a slow
|
||||
/// writer (canonically a refresh suspended across system sleep that
|
||||
/// completes after wake) rolls the stored refresh token back to a
|
||||
/// rotated-out value (`invalid_grant` on its next use).
|
||||
pub fn insert_and_save(
|
||||
&mut self,
|
||||
server_name: &str,
|
||||
server_url: &url::Url,
|
||||
creds: rmcp::transport::auth::StoredCredentials,
|
||||
) -> Result<()> {
|
||||
let key = Self::key(server_name, server_url);
|
||||
self.locked_mutate_and_save(&move |store: &mut Self| {
|
||||
if disk_entry_is_newer(store.entries.get(&key), &creds) {
|
||||
tracing::info!(
|
||||
key = key.as_str(),
|
||||
"mcp credentials: skipping stale save (disk entry is newer)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
store.entries.insert(key.clone(), creds.clone());
|
||||
})
|
||||
}
|
||||
|
||||
/// Save to a specific path.
|
||||
///
|
||||
/// Writes atomically via temp file + rename to prevent credential loss on
|
||||
|
|
@ -273,6 +288,18 @@ impl McpCredentialStore {
|
|||
self.entries.remove(&Self::key(server_name, server_url));
|
||||
}
|
||||
|
||||
/// Remove a server's credentials and persist, under the cross-process
|
||||
/// file lock (reload-merge → remove → atomic save). The locked
|
||||
/// counterpart of [`Self::remove`] + [`Self::save_default`] for callers
|
||||
/// that persist the removal — an unlocked whole-file rewrite can drop
|
||||
/// other processes' concurrent writes for unrelated servers.
|
||||
pub fn remove_and_save(&mut self, server_name: &str, server_url: &Url) -> Result<()> {
|
||||
let key = Self::key(server_name, server_url);
|
||||
self.locked_mutate_and_save(&move |store: &mut Self| {
|
||||
store.entries.remove(&key);
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove all credentials for a server by name (any URL).
|
||||
pub fn remove_by_server_name(&mut self, server_name: &str) -> usize {
|
||||
let prefix = format!("{server_name}:");
|
||||
|
|
@ -292,6 +319,23 @@ impl McpCredentialStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// `true` when the on-disk `existing` entry is strictly newer than the
|
||||
/// `incoming` credentials by `token_received_at` — the [`Self::insert_and_save`]
|
||||
/// freshness guard. Missing timestamps on either side compare as "not newer"
|
||||
/// (the write proceeds), preserving pre-guard behavior for expiry-less tokens.
|
||||
fn disk_entry_is_newer(
|
||||
existing: Option<&rmcp::transport::auth::StoredCredentials>,
|
||||
incoming: &rmcp::transport::auth::StoredCredentials,
|
||||
) -> bool {
|
||||
match (
|
||||
existing.and_then(|e| e.token_received_at),
|
||||
incoming.token_received_at,
|
||||
) {
|
||||
(Some(existing), Some(incoming)) => existing > incoming,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter implementing rmcp's `CredentialStore` trait backed by the on-disk
|
||||
/// `McpCredentialStore`. Each adapter instance is scoped to a single MCP server
|
||||
/// (keyed by name + URL); rmcp's `AuthorizationManager` calls load/save/clear
|
||||
|
|
@ -349,10 +393,13 @@ impl rmcp::transport::auth::CredentialStore for McpCredentialStoreAdapter {
|
|||
let name = self.server_name.clone();
|
||||
let url = self.server_url.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Under the same flock as `insert_and_save`: this is a whole-file
|
||||
// read-modify-write, and an unlocked snapshot here could silently
|
||||
// drop *other servers'* entries written concurrently by another
|
||||
// process (their just-rotated refresh tokens with them).
|
||||
let mut store = McpCredentialStore::load_default().unwrap_or_default();
|
||||
store.remove(&name, &url);
|
||||
store
|
||||
.save_default()
|
||||
.remove_and_save(&name, &url)
|
||||
.map_err(|e| rmcp::transport::auth::AuthError::InternalError(e.to_string()))
|
||||
})
|
||||
.await
|
||||
|
|
@ -515,4 +562,76 @@ mod tests {
|
|||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
/// The `insert_and_save` freshness guard: a save older (by
|
||||
/// `token_received_at`) than the on-disk entry must be skipped.
|
||||
#[test]
|
||||
fn stale_save_does_not_clobber_newer_disk_entry() {
|
||||
// `StoredCredentials` is #[non_exhaustive]; construct via `new` and
|
||||
// set the (public) timestamp field afterwards.
|
||||
let mut older = test_stored_creds("c");
|
||||
older.token_received_at = Some(1_000);
|
||||
let mut newer = test_stored_creds("c");
|
||||
newer.token_received_at = Some(2_000);
|
||||
let no_ts = test_stored_creds("c");
|
||||
|
||||
assert!(
|
||||
disk_entry_is_newer(Some(&newer), &older),
|
||||
"older incoming vs newer disk → skip the write"
|
||||
);
|
||||
assert!(
|
||||
!disk_entry_is_newer(Some(&older), &newer),
|
||||
"newer incoming vs older disk → write proceeds"
|
||||
);
|
||||
assert!(
|
||||
!disk_entry_is_newer(Some(&older), &older),
|
||||
"equal timestamps → write proceeds (idempotent re-save)"
|
||||
);
|
||||
assert!(
|
||||
!disk_entry_is_newer(None, &older),
|
||||
"no disk entry → write proceeds"
|
||||
);
|
||||
assert!(
|
||||
!disk_entry_is_newer(Some(&newer), &no_ts),
|
||||
"timestamp-less incoming keeps pre-guard behavior (writes)"
|
||||
);
|
||||
assert!(
|
||||
!disk_entry_is_newer(Some(&no_ts), &older),
|
||||
"timestamp-less disk entry keeps pre-guard behavior (writes)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The refresh-failure classifier that gates browser escalation
|
||||
/// (`force_reauth`): network-level failures — the `oauth2` crate's
|
||||
/// `Display` for request/parse errors — are transient; IdP rejections and
|
||||
/// missing-credential states stay terminal (escalate, as before).
|
||||
#[test]
|
||||
fn refresh_failure_transient_classification() {
|
||||
use crate::servers::mcp_refresh_failure_is_transient;
|
||||
use rmcp::transport::auth::AuthError;
|
||||
|
||||
// oauth2 RequestTokenError::Request renders exactly "Request failed".
|
||||
assert!(mcp_refresh_failure_is_transient(
|
||||
&AuthError::TokenRefreshFailed("Request failed".into())
|
||||
));
|
||||
// 5xx/proxy bodies that aren't OAuth JSON parse-fail.
|
||||
assert!(mcp_refresh_failure_is_transient(
|
||||
&AuthError::TokenRefreshFailed("Failed to parse server response".into())
|
||||
));
|
||||
|
||||
// IdP rejections carry the RFC 6749 code → terminal.
|
||||
assert!(!mcp_refresh_failure_is_transient(
|
||||
&AuthError::TokenRefreshFailed(
|
||||
"Server returned error response: invalid_grant: token revoked".into()
|
||||
)
|
||||
));
|
||||
// No refresh token at all → only the browser flow can help.
|
||||
assert!(!mcp_refresh_failure_is_transient(
|
||||
&AuthError::TokenRefreshFailed("No refresh token available".into())
|
||||
));
|
||||
// Empty credential store → interactive auth required.
|
||||
assert!(!mcp_refresh_failure_is_transient(
|
||||
&AuthError::AuthorizationRequired
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,22 @@ const MCP_OAUTH_CLIENT_NAME: &str = "Grok";
|
|||
/// a login completed in another window or process.
|
||||
const CREDENTIAL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Overall budget for one interactive browser consent flow (waiting for the
|
||||
/// loopback callback / disk poll after opening the browser). Mirrors the main
|
||||
/// grok.com login's 10-minute callback budget. Without a bound, an abandoned
|
||||
/// browser tab left the leader parked in its `select!` forever — holding both
|
||||
/// the in-process watch channel and the cross-process `mcp_auth_*.lock`, so
|
||||
/// every other session blocked indefinitely on the same server's auth.
|
||||
const BROWSER_AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// How long a follower waits for the cross-process auth lock before giving up
|
||||
/// on dedup and proceeding with its own flow. Slightly above
|
||||
/// [`BROWSER_AUTH_TIMEOUT`] so a legitimately-slow leader (user reading the
|
||||
/// consent screen) finishes first and the follower reuses its token.
|
||||
#[cfg(unix)]
|
||||
const AUTH_LOCK_WAIT: std::time::Duration =
|
||||
BROWSER_AUTH_TIMEOUT.saturating_add(std::time::Duration::from_secs(60));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-layer dedup: prevents duplicate browser tabs both within one process
|
||||
// (multiple async tasks / sessions) and across separate processes (leader
|
||||
|
|
@ -185,31 +201,49 @@ async fn authenticate_with_fs_lock(
|
|||
}
|
||||
};
|
||||
|
||||
// Bounded, non-blocking poll instead of an unbounded `flock(LOCK_EX)`:
|
||||
// the leader can legitimately hold this lock for minutes (user consent),
|
||||
// but an abandoned/wedged leader must not park followers forever. On
|
||||
// timeout we fall back to running our own flow (same as lock-acquisition
|
||||
// failure), which the token-changed re-check below keeps from producing a
|
||||
// duplicate consent when the leader did finish.
|
||||
let lock_file = tokio::task::spawn_blocking(move || {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = lock_file.as_raw_fd();
|
||||
let deadline = std::time::Instant::now() + AUTH_LOCK_WAIT;
|
||||
loop {
|
||||
if unsafe { libc::flock(fd, libc::LOCK_EX) } == 0 {
|
||||
if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0 {
|
||||
return Some(lock_file);
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
match err.kind() {
|
||||
std::io::ErrorKind::Interrupted => continue,
|
||||
std::io::ErrorKind::WouldBlock => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(_lock_guard) = lock_file else {
|
||||
tracing::warn!("Failed to acquire auth lock; proceeding without cross-process dedup");
|
||||
return run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await;
|
||||
};
|
||||
if lock_file.is_none() {
|
||||
tracing::warn!("Timed out waiting for auth lock; re-checking the store before a new flow");
|
||||
}
|
||||
// On timeout: proceed unlocked; the token-changed re-check below
|
||||
// dedups a leader that finished just past our deadline.
|
||||
let _lock_guard = lock_file;
|
||||
|
||||
// We hold the lock. Reload from disk and check if another process
|
||||
// wrote a DIFFERENT token while we waited (not just any token).
|
||||
// Reload from disk and check whether another process wrote a DIFFERENT
|
||||
// token while we waited (not just any token). This runs on the timeout
|
||||
// path too: a leader whose token exchange finished just past our deadline
|
||||
// has already written fresh tokens, and opening a second consent browser
|
||||
// would be strictly worse than this unlocked best-effort read.
|
||||
{
|
||||
let mut mgr = auth_manager.lock().await;
|
||||
if let Ok(true) = mgr.initialize_from_store().await {
|
||||
|
|
@ -436,6 +470,22 @@ async fn run_browser_auth_flow(
|
|||
"Fresh tokens detected on disk from another auth flow; skipping callback wait"
|
||||
);
|
||||
}
|
||||
// Abandoned consent: bound the wait so this leader releases the
|
||||
// in-process watch and the cross-process `mcp_auth_*.lock` instead of
|
||||
// wedging every future auth attempt for this server (see
|
||||
// `BROWSER_AUTH_TIMEOUT`).
|
||||
_ = tokio::time::sleep(BROWSER_AUTH_TIMEOUT) => {
|
||||
callback_server.abort();
|
||||
tracing::warn!(
|
||||
server = server_name,
|
||||
timeout_secs = BROWSER_AUTH_TIMEOUT.as_secs(),
|
||||
"OAuth consent timed out (browser flow abandoned?)"
|
||||
);
|
||||
return Err(format!(
|
||||
"OAuth consent timed out after {}s; re-run authentication to try again",
|
||||
BROWSER_AUTH_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ use xai_grok_tools::util::{ProcessGroup, ProcessScope};
|
|||
/// for callers that historically imported it from this module.
|
||||
pub use xai_grok_workspace_types::MCP_TOOL_NAME_DELIMITER;
|
||||
|
||||
/// Reqwest 0.13 adapter over `xai_grok_extra_ca::extra_root_ders` (DER is version-neutral).
|
||||
fn with_extra_root_certificates(mut builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
|
||||
for der in xai_grok_extra_ca::extra_root_ders() {
|
||||
match reqwest::Certificate::from_der(der) {
|
||||
Ok(cert) => builder = builder.add_root_certificate(cert),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest 0.13; skipping cert"
|
||||
),
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Normalize an MCP server URL for comparison: strip trailing slashes.
|
||||
/// Must match the normalization the host's managed-config layer uses
|
||||
/// (e.g. shell's `session::managed_mcp::normalize_url`) so refresh
|
||||
|
|
@ -1148,6 +1162,24 @@ impl McpError {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when a failed refresh-token grant was a **network-level** failure
|
||||
/// that never reached the IdP (RT validity unknown, presumed good); IdP
|
||||
/// rejections and missing credentials stay terminal (escalate to browser).
|
||||
/// rmcp 2.1 collapses the error into `TokenRefreshFailed(String)`, so this
|
||||
/// anchors on the `oauth2` crate's stable `Display` texts via
|
||||
/// `starts_with` (an IdP error description can't spoof a match):
|
||||
/// `"Request failed"` = network, `"Failed to parse server response"` =
|
||||
/// non-OAuth 5xx/proxy bodies; `"Server returned error response: …"` does
|
||||
/// NOT match.
|
||||
pub(crate) fn mcp_refresh_failure_is_transient(err: &rmcp::transport::auth::AuthError) -> bool {
|
||||
match err {
|
||||
rmcp::transport::auth::AuthError::TokenRefreshFailed(msg) => {
|
||||
msg.starts_with("Request failed") || msg.starts_with("Failed to parse server response")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if an MCP error *message* indicates an auth rejection (vs. a transport
|
||||
/// drop, timeout, or protocol error), so host recovery can decide whether a
|
||||
/// credential re-fetch would help.
|
||||
|
|
@ -2037,6 +2069,7 @@ impl SafeTokioChildProcess {
|
|||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
#[allow(clippy::disallowed_methods)] // enrolled in the session scope below
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
|
|
@ -2810,7 +2843,12 @@ impl McpClient {
|
|||
/// Tries in order:
|
||||
/// 1. Reload from disk (picks up tokens from background auth task)
|
||||
/// 2. Refresh via refresh_token grant
|
||||
/// 3. Full browser-based OAuth flow
|
||||
/// 3. Full browser-based OAuth flow — unless the refresh failure was a
|
||||
/// pure network failure ([`mcp_refresh_failure_is_transient`]): the
|
||||
/// stored refresh token is then still presumed valid, and opening a
|
||||
/// browser tab / re-running DCR for a Wi-Fi blip right after
|
||||
/// wake-from-sleep is both useless (the IdP is unreachable for the
|
||||
/// browser too) and destructive (it discards a working credential).
|
||||
pub async fn force_reauth(&self, force: bool) -> bool {
|
||||
let (Some(auth_mgr), Some(config)) = (&self.auth_manager, &self.http_config) else {
|
||||
return false;
|
||||
|
|
@ -2861,12 +2899,13 @@ impl McpClient {
|
|||
}
|
||||
|
||||
// Try token refresh.
|
||||
let refresh_ok = {
|
||||
let refresh_result = {
|
||||
let mgr = auth_mgr.lock().await;
|
||||
mgr.refresh_token().await.is_ok()
|
||||
mgr.refresh_token().await
|
||||
};
|
||||
|
||||
if refresh_ok {
|
||||
match refresh_result {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
server = self.server_name.as_str(),
|
||||
"Token refreshed successfully (no browser)"
|
||||
|
|
@ -2878,6 +2917,26 @@ impl McpClient {
|
|||
.await;
|
||||
return true;
|
||||
}
|
||||
// Transient (network never reached the IdP): fail the attempt
|
||||
// instead of discarding a presumed-good credential — the retry
|
||||
// paths re-run the refresh once the network is back. An explicit
|
||||
// user trigger (`force`) still opens the browser.
|
||||
Err(ref e) if !force && mcp_refresh_failure_is_transient(e) => {
|
||||
tracing::warn!(
|
||||
server = self.server_name.as_str(),
|
||||
error = %e,
|
||||
"Token refresh failed transiently (network); skipping browser escalation"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
server = self.server_name.as_str(),
|
||||
error = %e,
|
||||
"Token refresh failed terminally; falling back to browser auth"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Full browser-based OAuth flow.
|
||||
{
|
||||
|
|
@ -3416,12 +3475,11 @@ impl McpClient {
|
|||
}
|
||||
}
|
||||
ensure_figma_user_agent(&mut headers, name, &config.url);
|
||||
let http_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
let http_client = with_extra_root_certificates(
|
||||
reqwest::Client::builder().default_headers(headers),
|
||||
)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
McpError::ClientError(format!("Failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
.map_err(|e| McpError::ClientError(format!("Failed to build HTTP client: {e}")))?;
|
||||
// `AuthClient::new` wants an owned manager, but ours is shared
|
||||
// (`Arc`) with the OAuth flow; the struct is non_exhaustive, so
|
||||
// build with a throwaway manager and swap in the shared one.
|
||||
|
|
@ -3626,8 +3684,8 @@ impl McpClient {
|
|||
}
|
||||
}
|
||||
ensure_figma_user_agent(&mut headers, server_name, &config.url);
|
||||
let client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
let client =
|
||||
with_extra_root_certificates(reqwest::Client::builder().default_headers(headers))
|
||||
.build()
|
||||
.map_err(|e| McpError::ClientError(format!("Failed to build HTTP client: {e}")))?;
|
||||
let mcp_http_client =
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ pub fn run_with_timeout(
|
|||
/// race. It is transient and clears within milliseconds, so retry a few times
|
||||
/// with a short backoff. (No-op on the steady-state path; only the failing
|
||||
/// transient case changes behaviour.)
|
||||
#[allow(clippy::disallowed_methods)] // the caller owns the reap
|
||||
fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
let mut attempt = 0;
|
||||
|
|
@ -273,6 +274,7 @@ mod tests {
|
|||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
let mut cmd = detached(cmd);
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
|
||||
let mut child = cmd.spawn().expect("spawn sleep");
|
||||
let pid = child.id() as libc::pid_t;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager-bin"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
|
|||
|
|
@ -2156,6 +2156,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
yolo: launch_yolo.yolo,
|
||||
trust: args.trust,
|
||||
output_format: args.output_format,
|
||||
include_partial_messages: args.include_partial_messages,
|
||||
json_schema,
|
||||
model: args.model,
|
||||
rules: args.rules,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub fn pbcopy(text: &str) -> Result<()> {
|
|||
let mut cmd = Command::new("pbcopy");
|
||||
cmd.stdin(Stdio::piped());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below
|
||||
let mut child = cmd.spawn().context("spawn pbcopy")?;
|
||||
child
|
||||
.stdin
|
||||
|
|
@ -52,6 +53,7 @@ pub fn pbcopy(text: &str) -> Result<()> {
|
|||
])
|
||||
.stdin(Stdio::piped());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below
|
||||
let mut child = cmd.spawn().context("spawn powershell Set-Clipboard")?;
|
||||
child
|
||||
.stdin
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ fn write_tmux_buffer(text: &str) -> bool {
|
|||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below
|
||||
let mut child = cmd.spawn()?;
|
||||
// Bounded wait: a wedged tmux server must not freeze the UI thread.
|
||||
let status = xai_grok_shared::clipboard::wait_with_deadline(
|
||||
|
|
|
|||
|
|
@ -482,6 +482,21 @@ pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> {
|
|||
Cow::Owned(to_legacy_glyphs(s))
|
||||
}
|
||||
|
||||
/// Single-row toast sinks: glyph fallback, then map control chars to spaces.
|
||||
/// Borrows when the input is already clean (common path).
|
||||
pub fn sanitize_toast_message(msg: &str) -> Cow<'_, str> {
|
||||
let glyph = legacy_glyph_fallback(msg);
|
||||
if !glyph.chars().any(char::is_control) {
|
||||
return glyph;
|
||||
}
|
||||
Cow::Owned(
|
||||
glyph
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure glyph → legacy-safe mapping behind [`legacy_glyph_fallback`], split
|
||||
/// out so tests can exercise the substitution without faking the host probe.
|
||||
/// `√` matches [`check_mark`]'s fallback; `x` matches [`ballot_x`]'s.
|
||||
|
|
@ -734,6 +749,22 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_toast_message_borrows_when_clean() {
|
||||
assert!(!is_legacy_windows_console());
|
||||
assert!(matches!(
|
||||
sanitize_toast_message("plain toast"),
|
||||
Cow::Borrowed("plain toast")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_toast_message_maps_controls_to_spaces() {
|
||||
let out = sanitize_toast_message("a\nb\tc");
|
||||
assert_eq!(out.as_ref(), "a b c");
|
||||
assert!(!out.chars().any(char::is_control));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_legacy_console_override_parses_known_values() {
|
||||
assert_eq!(parse_forced_legacy_console(Some("1")), Some(true));
|
||||
|
|
|
|||
|
|
@ -45,10 +45,23 @@ pub fn browser_open_likely_available() -> bool {
|
|||
browser_open_likely_available_from_env(&env)
|
||||
}
|
||||
|
||||
/// User-facing copy when the browser opener cannot run. Includes the full
|
||||
/// URL on its own line so it is easy to select/copy in the TUI.
|
||||
const BROWSER_UNAVAILABLE_NOTICE: &str = "Could not open a browser. Open this URL manually";
|
||||
|
||||
/// Multi-line copy for agent scrollback: notice, then the full URL alone
|
||||
/// so it is easy to select/copy in the TUI.
|
||||
pub fn browser_unavailable_message(url: &str) -> String {
|
||||
format!("Could not open a browser. Open this URL manually:\n{url}")
|
||||
format!("{BROWSER_UNAVAILABLE_NOTICE}:\n{url}")
|
||||
}
|
||||
|
||||
/// Single-line welcome toast: URL first so prefix truncation keeps the
|
||||
/// destination. `copied` is true only when clipboard delivery reported
|
||||
/// success — never claim a copy that did not happen.
|
||||
pub fn browser_unavailable_line(url: &str, copied: bool) -> String {
|
||||
if copied {
|
||||
format!("{url} — {BROWSER_UNAVAILABLE_NOTICE} (URL copied)")
|
||||
} else {
|
||||
format!("{url} — {BROWSER_UNAVAILABLE_NOTICE}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a URL in the system's default browser/handler.
|
||||
|
|
@ -59,10 +72,12 @@ pub fn browser_unavailable_message(url: &str) -> String {
|
|||
///
|
||||
/// Returns `true` when the opener was launched (or the test seam recorded
|
||||
/// the URL). Returns `false` when the environment looks headless or spawn
|
||||
/// fails — callers should show [`browser_unavailable_message`].
|
||||
/// fails — callers should surface the URL via [`browser_unavailable_message`]
|
||||
/// (scrollback) or [`browser_unavailable_line`] (welcome toast).
|
||||
///
|
||||
/// **Callers handling untrusted input** should call [`is_safe_to_open`]
|
||||
/// first, or use [`open_url_if_safe`] / [`try_open_url`] which combine both.
|
||||
#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits
|
||||
pub fn open_url(url: &str) -> bool {
|
||||
// Test seam: PTY e2e must observe the open without launching a real
|
||||
// browser. When set, append the URL to the file and skip the OS opener.
|
||||
|
|
@ -156,6 +171,7 @@ fn build_open_path_command(path: &std::path::Path) -> std::process::Command {
|
|||
/// expansion corrupts the percent-encoded session-directory segment in
|
||||
/// imagine media paths (e.g. `…\C%3A%5CUsers…`).
|
||||
/// - **macOS / Linux**: `open` / `xdg-open` open the file in its default app.
|
||||
#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits
|
||||
pub fn open_path(path: &std::path::Path) -> bool {
|
||||
// Never launch a real GUI app in tests.
|
||||
#[cfg(test)]
|
||||
|
|
@ -189,6 +205,7 @@ pub fn open_path(path: &std::path::Path) -> bool {
|
|||
/// Prefer the on-disk path as-is. When the file is missing, open the parent
|
||||
/// folder (no `/select`) so the user lands near the media instead of Home.
|
||||
#[cfg(all(not(test), target_os = "windows"))]
|
||||
#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits
|
||||
fn reveal_in_explorer(path: &std::path::Path) -> bool {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
|
|
@ -569,11 +586,34 @@ mod tests {
|
|||
#[test]
|
||||
fn browser_unavailable_message_includes_full_url() {
|
||||
let url = "https://grok.com/supergrok?referrer=grok-build";
|
||||
let msg = browser_unavailable_message(url);
|
||||
assert!(msg.contains("Could not open a browser"));
|
||||
assert!(msg.contains(url));
|
||||
// URL on its own line for easy select/copy in the TUI.
|
||||
assert!(msg.lines().any(|l| l == url));
|
||||
assert_eq!(
|
||||
browser_unavailable_message(url),
|
||||
format!("{BROWSER_UNAVAILABLE_NOTICE}:\n{url}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_unavailable_line_is_url_first_single_line() {
|
||||
let url = "https://grok.com/supergrok?referrer=grok-build";
|
||||
let plain = browser_unavailable_line(url, false);
|
||||
assert!(plain.starts_with(url), "{plain}");
|
||||
assert!(!plain.contains('\n'), "{plain}");
|
||||
assert!(
|
||||
!plain.to_ascii_lowercase().contains("copied"),
|
||||
"must not claim copy on failure: {plain}"
|
||||
);
|
||||
assert!(
|
||||
plain.contains(BROWSER_UNAVAILABLE_NOTICE),
|
||||
"shares notice stem with multi-line form: {plain}"
|
||||
);
|
||||
|
||||
let with_copy = browser_unavailable_line(url, true);
|
||||
assert!(with_copy.starts_with(url), "{with_copy}");
|
||||
assert!(!with_copy.contains('\n'), "{with_copy}");
|
||||
assert!(
|
||||
with_copy.contains("URL copied"),
|
||||
"copy claim only when copied=true: {with_copy}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ fn run_tmux_bounded(
|
|||
timeout: Duration,
|
||||
) -> Result<TmuxCommandOutput, String> {
|
||||
let mut command = build_tmux_command(command);
|
||||
#[allow(clippy::disallowed_methods)] // bounded probe, waited on with a timeout
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to run tmux: {error}"))?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.114"
|
||||
version = "0.2.116"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
@ -196,6 +196,10 @@ harness = false
|
|||
name = "edit_highlight"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "resize"
|
||||
harness = false
|
||||
|
||||
# PTY integration tests are split into coherent scheduling families. The test
|
||||
# modules remain under tests/pty_e2e/; these roots only define which cases share
|
||||
# one Cargo/Bazel process. All cases stay #[ignore]d for ordinary Cargo runs.
|
||||
|
|
|
|||
195
crates/codegen/xai-grok-pager/benches/resize.rs
Normal file
195
crates/codegen/xai-grok-pager/benches/resize.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
//! Criterion benchmarks for the terminal-RESIZE path.
|
||||
//!
|
||||
//! Dragging a terminal edge sends a stream of `Event::Resize`, and the
|
||||
//! reported symptom is that the drag gets laggier the longer a session runs.
|
||||
//!
|
||||
//! Regressions these guard against, each of which was measured on a real
|
||||
//! session and removed:
|
||||
//! - re-deriving an entry's source text per width instead of reusing its
|
||||
//! cached line-width profile (makes the estimate pass O(conversation bytes)),
|
||||
//! - building or cloning an `AppearanceConfig` per entry,
|
||||
//! - running `warm_measure_pages_above` on every resize instead of once the
|
||||
//! width settles.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
|
||||
use xai_grok_pager::scrollback::{RenderBlock, ScrollbackState};
|
||||
|
||||
/// Roughly a VS Code editor pane maximized on a laptop screen.
|
||||
const VIEWPORT_WIDTH: u16 = 120;
|
||||
const VIEWPORT_HEIGHT: u16 = 50;
|
||||
|
||||
/// ~3,200 entries / ~5 MB of text — the scale of a multi-hour session.
|
||||
const TURNS: usize = 400;
|
||||
|
||||
fn agent_markdown(i: usize) -> String {
|
||||
format!(
|
||||
"Here is what I found for step {i}.\n\n\
|
||||
The `ScrollbackState` keeps a layout cache keyed by width, so a resize \
|
||||
invalidates every entry. That matters because the estimate pass has to \
|
||||
re-derive each block's source text before it can compute a height.\n\n\
|
||||
- first observation about entry {i}\n\
|
||||
- second observation, slightly longer, about how the wrap cache is keyed \
|
||||
on `(width, generation, theme)` and therefore misses after a drag\n\
|
||||
- third observation\n\n\
|
||||
```rust\n\
|
||||
fn rebuild_layout_cache(&mut self, width: u16) {{\n\
|
||||
\x20 for entry in self.entries.values() {{\n\
|
||||
\x20 let renderer = EntryRenderer::new(entry, &theme)\n\
|
||||
\x20 .with_appearance(self.appearance.clone());\n\
|
||||
\x20 let height = renderer.estimate_height(width);\n\
|
||||
\x20 }}\n\
|
||||
}}\n\
|
||||
```\n\n\
|
||||
In short: the {i}th response re-wraps on every width change, and the \
|
||||
syntax highlighting of the fence above is recomputed with it. \
|
||||
{}\n",
|
||||
"Additional prose so the message spans several wrapped rows. ".repeat(6)
|
||||
)
|
||||
}
|
||||
|
||||
fn thinking_text(i: usize) -> String {
|
||||
format!(
|
||||
"Considering approach {i}. {}",
|
||||
"The user asked about resize latency, so I should look at the layout cache. ".repeat(8)
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_texts(i: usize) -> (String, String) {
|
||||
let old = format!(
|
||||
"fn handler_{i}(req: Request) -> Response {{\n\
|
||||
\x20 let body = req.body();\n\
|
||||
\x20 let parsed = serde_json::from_slice(body)?;\n\
|
||||
\x20 Response::ok(parsed)\n\
|
||||
}}\n"
|
||||
);
|
||||
let new = format!(
|
||||
"fn handler_{i}(req: Request) -> Response {{\n\
|
||||
\x20 let body = req.body();\n\
|
||||
\x20 let parsed: Payload = serde_json::from_slice(body)\n\
|
||||
\x20 .map_err(|e| Error::BadRequest(e.to_string()))?;\n\
|
||||
\x20 Response::ok(parsed)\n\
|
||||
}}\n"
|
||||
);
|
||||
(old, new)
|
||||
}
|
||||
|
||||
fn bash_output(i: usize) -> String {
|
||||
(0..40)
|
||||
.map(|l| format!("crates/codegen/xai-grok-pager/src/file_{i}_{l}.rs:{l}: match found"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Block mix and proportions follow what a real coding session produces.
|
||||
fn build_session() -> (ScrollbackState, usize) {
|
||||
let mut state = ScrollbackState::new();
|
||||
let mut bytes = 0usize;
|
||||
let mut push = |state: &mut ScrollbackState, block: RenderBlock, n: usize| {
|
||||
bytes += n;
|
||||
state.push_block(block);
|
||||
};
|
||||
for i in 0..TURNS {
|
||||
let p = format!("please investigate issue {i} and report back with a plan");
|
||||
push(&mut state, RenderBlock::user_prompt(p.clone()), p.len());
|
||||
let t = thinking_text(i);
|
||||
push(&mut state, RenderBlock::thinking(t.clone()), t.len());
|
||||
let out = bash_output(i);
|
||||
push(
|
||||
&mut state,
|
||||
RenderBlock::execute_with_output(
|
||||
format!("rg -n 'pattern{i}' crates/"),
|
||||
out.clone(),
|
||||
None::<String>,
|
||||
),
|
||||
out.len(),
|
||||
);
|
||||
push(
|
||||
&mut state,
|
||||
RenderBlock::read(
|
||||
format!("crates/codegen/xai-grok-pager/src/mod_{i}.rs"),
|
||||
None,
|
||||
),
|
||||
64,
|
||||
);
|
||||
let (old, new) = edit_texts(i);
|
||||
push(
|
||||
&mut state,
|
||||
RenderBlock::edit_with_hunks(
|
||||
format!("crates/codegen/xai-grok-pager/src/mod_{i}.rs"),
|
||||
xai_grok_pager::diff::diff_hunks_from_strings(&old, &new, 1),
|
||||
),
|
||||
old.len() + new.len(),
|
||||
);
|
||||
push(
|
||||
&mut state,
|
||||
RenderBlock::search(format!("fn handler_{i}"), 12, Vec::new()),
|
||||
48,
|
||||
);
|
||||
let md = agent_markdown(i);
|
||||
push(&mut state, RenderBlock::agent_message(md.clone()), md.len());
|
||||
let t2 = thinking_text(i + 1);
|
||||
push(&mut state, RenderBlock::thinking(t2.clone()), t2.len());
|
||||
}
|
||||
state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
(state, bytes)
|
||||
}
|
||||
|
||||
fn bench_resize_step(c: &mut Criterion) {
|
||||
let (mut state, bytes) = build_session();
|
||||
eprintln!(
|
||||
"resize corpus: {} entries, ~{:.1} MB text",
|
||||
state.len(),
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let mut g = c.benchmark_group("resize");
|
||||
g.sample_size(20).warm_up_time(Duration::from_millis(500));
|
||||
g.bench_function("width_step", |b| {
|
||||
let mut w = VIEWPORT_WIDTH;
|
||||
b.iter(|| {
|
||||
w = if w == VIEWPORT_WIDTH {
|
||||
VIEWPORT_WIDTH - 1
|
||||
} else {
|
||||
VIEWPORT_WIDTH
|
||||
};
|
||||
state.prepare_layout(w, VIEWPORT_HEIGHT);
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_resize_drag(c: &mut Criterion) {
|
||||
let (mut state, _) = build_session();
|
||||
let mut g = c.benchmark_group("resize");
|
||||
g.sample_size(10).warm_up_time(Duration::from_millis(500));
|
||||
g.bench_function("drag_20_steps", |b| {
|
||||
b.iter(|| {
|
||||
for step in 0..20u16 {
|
||||
state.prepare_layout(VIEWPORT_WIDTH - step, VIEWPORT_HEIGHT);
|
||||
}
|
||||
state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_resize_noop(c: &mut Criterion) {
|
||||
let (mut state, _) = build_session();
|
||||
let mut g = c.benchmark_group("resize");
|
||||
g.bench_function("same_width_noop", |b| {
|
||||
b.iter(|| {
|
||||
state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_resize_step,
|
||||
bench_resize_drag,
|
||||
bench_resize_noop
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
|
@ -11,7 +11,7 @@ A few worth knowing on day one:
|
|||
| `/new` | Start a fresh session |
|
||||
| `/compact` | Compress a long conversation to free up context |
|
||||
| `/btw` | Send Grok an aside *without* interrupting its current task |
|
||||
| `/rewind` | Restore your files and history to an earlier prompt |
|
||||
| `/rewind` (alias `/undo`) | Restore your files and history to an earlier prompt |
|
||||
| `/docs` | Full How-to Guides, in the TUI or on the web |
|
||||
| `/feedback` | Send feedback to the team |
|
||||
|
||||
|
|
@ -20,8 +20,8 @@ Two of those deserve a second look:
|
|||
- **`/compact`** takes an optional hint: `/compact keep the auth details`.
|
||||
Check context usage anytime with `/context` — Grok also auto-compacts
|
||||
when the window fills up.
|
||||
- **`/rewind`** restores actual file snapshots taken at each prompt, not
|
||||
just the chat.
|
||||
- **`/rewind`** (or **`/undo`**) restores actual file snapshots taken at each
|
||||
prompt, not just the chat.
|
||||
|
||||
## The command palette
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Type `/` in the prompt to open the command menu. It fuzzy-matches as you type, and picking a command runs it immediately.
|
||||
|
||||
Commands come from two places: **shell builtins**, handled by the agent backend (xai-grok-shell), and **pager builtins**, handled by the TUI frontend (xai-grok-pager). Both show up in the same menu, and any enabled skill with `user-invocable: true` appears there too.
|
||||
Commands come from two places: **shell builtins**, handled by the agent backend (xai-grok-shell), and **pager builtins**, handled by the pager frontend (xai-grok-pager). Both show up in the same menu, and any enabled skill with `user-invocable: true` appears there too.
|
||||
|
||||
Every command below lists its aliases where it has them. A few commands only appear when a feature or session state enables them; those cases are called out inline.
|
||||
Every command below lists its aliases where it has them. A few commands only appear when a feature or session state enables them; those cases are called out inline. The menu is also filtered by render mode — see [`/minimal` and `/fullscreen`](#minimal-and-fullscreen).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -47,9 +47,9 @@ Show session details — auth method, model, turn count, and context usage. Alia
|
|||
|
||||
Branch the current session into a new agent, keeping history up to this point.
|
||||
|
||||
### `/rewind`
|
||||
### `/rewind` (alias: `/undo`)
|
||||
|
||||
Roll the conversation back to an earlier turn and discard everything after it.
|
||||
Roll the conversation back to an earlier turn and discard everything after it. `/undo` is the same command.
|
||||
|
||||
### `/edit-prompt`
|
||||
|
||||
|
|
@ -153,7 +153,9 @@ Toggle vim-style scrollback keys (`j`/`k`, `h`/`l`, `g`/`G`, `y`/`Y`, and so on)
|
|||
|
||||
### `/minimal` and `/fullscreen`
|
||||
|
||||
Reopen the current session in the other render mode. `/minimal` (offered while you're in fullscreen) switches to the experimental scrollback-native mode; `/fullscreen` (offered while you're in minimal; alias `/full`) switches back to the standard alt-screen TUI. Both relaunch the pager on the same conversation for this session only — they don't touch `config.toml`, and the relaunch banner reminds you how to switch back. The `--minimal` / `--fullscreen` CLI flags are session-scoped the same way. To make plain `grok` open in a given mode by default, use `/settings` → **Default screen mode** or set `[ui] screen_mode`.
|
||||
Reopen the current session in the other render mode. `/minimal` (offered while you're in fullscreen) switches to the experimental scrollback-native mode; `/fullscreen` (offered while you're in minimal; alias `/full`) switches back to standard fullscreen mode. Both relaunch the pager on the same conversation for this session only — they don't touch `config.toml`, and the relaunch banner reminds you how to switch back. The `--minimal` / `--fullscreen` CLI flags are session-scoped the same way. To make plain `grok` open in a given mode by default, use `/settings` → **Default screen mode** or set `[ui] screen_mode`.
|
||||
|
||||
A handful of commands only work in one of the two modes, because the surface they drive doesn't exist in the other: `/find`, `/jump`, `/timeline`, `/theme`, `/tutorial`, `/workflows`, and `/dashboard` are fullscreen-only, while `/expand` and `/edit-prompt` are minimal-only. Those are hidden from the command menu and the palette in the mode they can't run in. If you type one out anyway, Grok says why — and points you at whichever is actually useful. When the other mode is the only way to get it, that's the mode switch: `/theme isn't available in minimal mode (minimal renders with your terminal's own palette). Run /fullscreen to switch this session.` When this mode already does the job another way, it names that instead: `/expand isn't available in fullscreen mode — press Tab to focus the scrollback, then → on the block.` Everything else works in both. Note that `--no-alt-screen` still counts as fullscreen here, so it keeps the fullscreen-only commands.
|
||||
|
||||
### `/plan`
|
||||
|
||||
|
|
@ -208,13 +210,13 @@ Save a note to memory immediately, without waiting for an automatic summary.
|
|||
|
||||
Open the extensions modal on the Hooks tab, where you can view loaded hooks, add or remove custom ones, and toggle them individually. The modal does not grant project trust — see [10-hooks.md](10-hooks.md) for the trust model.
|
||||
|
||||
The shell also advertises individual `/hooks-list`, `/hooks-trust`, `/hooks-add`, `/hooks-remove`, and `/hooks-untrust` commands; in the TUI pager these are folded into the `/hooks` modal.
|
||||
The shell also advertises individual `/hooks-list`, `/hooks-trust`, `/hooks-add`, `/hooks-remove`, and `/hooks-untrust` commands; in the pager these are folded into the `/hooks` modal.
|
||||
|
||||
### `/plugins`
|
||||
|
||||
Open the extensions modal on the Plugins tab to view installed plugins, install new ones from the marketplace, and manage trust.
|
||||
|
||||
The shell additionally supports subcommands (`/plugins list`, `/plugins install <source>`, `/plugins uninstall <name>`, `/plugins update`, `/plugins reload`). In the TUI, the modal does the same work visually.
|
||||
The shell additionally supports subcommands (`/plugins list`, `/plugins install <source>`, `/plugins uninstall <name>`, `/plugins update`, `/plugins reload`). In the pager, the modal does the same work visually.
|
||||
|
||||
### `/marketplace`
|
||||
|
||||
|
|
@ -315,7 +317,7 @@ Open the live workflows **run** dashboard — active and retained runs, not a ca
|
|||
|
||||
### `/theme`
|
||||
|
||||
Switch the TUI color theme. Alias: `/t`.
|
||||
Switch the color theme. Alias: `/t`.
|
||||
|
||||
### `/feedback [message]`
|
||||
|
||||
|
|
@ -347,7 +349,7 @@ View release notes for the current version. Alias: `/changelog`.
|
|||
|
||||
### `/docs`
|
||||
|
||||
Browse the in-TUI How-to Guides, open the online Build docs, or jump straight to a guide by title. Aliases: `/howto`, `/guides`.
|
||||
Browse the built-in How-to Guides, open the online Build docs, or jump straight to a guide by title. Aliases: `/howto`, `/guides`.
|
||||
|
||||
```
|
||||
/docs
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ Grok processes the prompt, runs any necessary tools, and prints the result to st
|
|||
| ----------------------- | ----------------------------------------------------- |
|
||||
| `-p, --single <PROMPT>` | The prompt to send (or use `--prompt-json` / `--prompt-file`) |
|
||||
| `-m, --model <MODEL>` | Model to use (e.g., `grok-build`) |
|
||||
| `-s, --session-id <ID>` | Create a **new** session with this **UUID** (errors if invalid UUID or already in use under the target session directory; does not resume — use `-r`/`-c`) |
|
||||
| `-s, --session-id <ID>` | Create a **new** session with this **UUID** (errors if invalid UUID or already in use under the target session directory; does not resume, use `-r`/`-c`) |
|
||||
| `--fork-session` | With `-r`/`-c`, fork into a new session ID instead of appending to the original |
|
||||
| `-r, --resume <ID_OR_TITLE>` | Resume an existing session by ID, or by title for the current directory, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path; scripts should prefer IDs) |
|
||||
| `-c, --continue` | Continue the most recent session in current directory |
|
||||
| `--cwd <PATH>` | Set working directory |
|
||||
| `--output-format <FMT>` | Output format: `plain`, `json`, `streaming-json` |
|
||||
| `--output-format <FMT>` | Output format: `plain`, `json`, `streaming-json`, `streaming-messages-json` |
|
||||
| `--include-partial-messages` | Emit raw `stream_event` deltas. Only affects `--output-format streaming-messages-json`; ignored (with a warning) otherwise. |
|
||||
| `--yolo` | Auto-approve all tool executions |
|
||||
| `--rules <TEXT>` | Custom rules for the system prompt |
|
||||
| `--tools <TOOLS>` | Allowlist of built-in tools (comma-separated). MCP meta-tools remain available unless denied. Headless only. |
|
||||
|
|
@ -115,7 +116,7 @@ grok -p "Build the project" --allow "Bash"
|
|||
|
||||
## Output Formats
|
||||
|
||||
Headless mode supports three output formats, selected with `--output-format`.
|
||||
Headless mode supports four output formats, selected with `--output-format`.
|
||||
|
||||
### plain (default)
|
||||
|
||||
|
|
@ -130,18 +131,20 @@ Here's a summary of the codebase...
|
|||
A single JSON object emitted after the response completes: response text,
|
||||
stop reason, session ID, request ID (plus `thought` when reasoning is present).
|
||||
When the prompt reached the model, the same object also carries spend fields
|
||||
(`usage`, `num_turns`, `modelUsage`, cost).
|
||||
(`usage`, `num_turns`, `modelUsage`, cost). `stopReason` is the snake_case
|
||||
ACP/Messages token (`end_turn`, `max_tokens`, …).
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Here's a summary of the codebase...",
|
||||
"stopReason": "EndTurn",
|
||||
"stopReason": "end_turn",
|
||||
"sessionId": "abc123",
|
||||
"requestId": "xyz789",
|
||||
"num_turns": 7,
|
||||
"usage": {
|
||||
"input_tokens": 7210,
|
||||
"cache_read_input_tokens": 41000,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"output_tokens": 1893,
|
||||
"reasoning_tokens": 412,
|
||||
"total_tokens": 50103
|
||||
|
|
@ -168,8 +171,8 @@ Usage notes:
|
|||
- **Token field policy (headless result / `end` / error spend):**
|
||||
- `usage.input_tokens` and `modelUsage.*.inputTokens` are **uncached only**.
|
||||
- `cache_read_input_tokens` / `cacheReadInputTokens` are cache hits.
|
||||
- `total_tokens` is full input + output (includes cache):
|
||||
`total_tokens = input_tokens + cache_read_input_tokens + output_tokens`.
|
||||
- `total_tokens` is full input + output (includes both cache buckets):
|
||||
`total_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens`.
|
||||
- ACP `_meta.usage.inputTokens` (PromptUsage) is still the **full** prompt
|
||||
sum; only the headless projector subtracts cache. Prefer headless fields
|
||||
for spend automation.
|
||||
|
|
@ -208,29 +211,119 @@ failures may also include frozen spend fields when usage was recorded:
|
|||
|
||||
### streaming-json
|
||||
|
||||
Newline-delimited JSON events emitted in real time. Each line is a self-contained JSON object with a `type` field:
|
||||
Newline-delimited JSON, one `type`-tagged object per line, derived from the agent's ACP session updates. Leaf field names (`toolCallId`, `kind`, `rawInput`, `rawOutput`) follow ACP; `toolName` and the `usage` line are xAI additions. Consume it by switching on `type`.
|
||||
|
||||
```json
|
||||
{"type":"text","data":"Here's"}
|
||||
{"type":"text","data":" a summary"}
|
||||
{"type":"thought","data":"Analyzing the directory structure..."}
|
||||
{"type":"end","stopReason":"EndTurn","sessionId":"abc123","requestId":"xyz789","usage":{...},"num_turns":7,"modelUsage":{...}}
|
||||
{"type":"tool_call","toolCallId":"call_1","title":"Read","kind":"read","status":"in_progress","toolName":"read_file","rawInput":{"path":"src/main.rs"},"content":[],"locations":[]}
|
||||
{"type":"tool_call_update","toolCallId":"call_1","status":"completed","content":[],"rawOutput":{"lines":42},"locations":[]}
|
||||
{"type":"text","data":"Here's a summary"}
|
||||
{"type":"usage","messageId":"resp_1","stopReason":"end_turn","usage":{"input_tokens":812,"output_tokens":45,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"reasoning_tokens":0},"signature":"..."}
|
||||
{"type":"end","stopReason":"end_turn","sessionId":"abc123","requestId":"xyz789","usage":{...},"num_turns":7,"modelUsage":{...}}
|
||||
```
|
||||
|
||||
Event types:
|
||||
|
||||
| Type | Description |
|
||||
| ---------- | -------------------------------------------------------------- |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `text` | A chunk of the agent's response text |
|
||||
| `thought` | Internal reasoning (thinking tokens) |
|
||||
| `tool_call` | A tool call the agent started (`toolCallId`, `toolName`, `kind`, `status`, `rawInput`, `content`, `locations`) |
|
||||
| `tool_call_update` | Progress or result for a tool call (`status`, `rawOutput`, `content`, `locations`) |
|
||||
| `usage` | Per-response boundary (`messageId`, `stopReason`, `usage`, `signature`), one per model response |
|
||||
| `plan` | The agent's current plan (`entries`) |
|
||||
| `available_commands` | Tool and slash command lists (`tools`, `commands`) |
|
||||
| `end` | Final event with metadata and spend fields when available |
|
||||
| `error` | An error occurred (carries `message`, and spend fields if any) |
|
||||
|
||||
`end` is always the last event. Spend fields on `end` match the json object
|
||||
shape (snake_case uncached `input_tokens`, safe cost floats).
|
||||
shape (snake_case uncached `input_tokens`, safe cost floats). `end.stopReason`
|
||||
is the turn stop reason in snake_case (`end_turn`, `max_tokens`,
|
||||
`max_turn_requests`, `refusal`, `cancelled`); the verbatim per-response provider
|
||||
reason (e.g. `tool_use`, `pause_turn`) is on the `usage` line's `stopReason`.
|
||||
Per-response `message_id`/`stopReason`/`signature` are populated on the Messages
|
||||
API backend; other backends report what they carry.
|
||||
|
||||
Grok may also emit `max_turns_reached` and `auto_compact_*` events; treat the list as non-exhaustive and switch on `type`.
|
||||
|
||||
### streaming-messages-json
|
||||
|
||||
Newline-delimited JSON in the Messages API `stream-json` wire format. The data-bearing surface matches the Messages shape exactly. This includes the `assistant`/`user` message bodies, `usage`, `tool_use`/`tool_result`, inline web search, `stop_reason`, and the `--include-partial-messages` event framing. A consumer that reconstructs messages, reads spend, or detects errors works without changes.
|
||||
|
||||
The `system`/`init` and terminal `result` lines carry metadata. Grok emits the fields it has real data for and omits pure-placeholder fields it cannot fill, rather than zero-filling them. As a result, those two lines may not pass strict `init`/`result` schema validation. The individual fields are listed below. Read the fidelity notes before treating any one field as authoritative. For a clean xAI-native stream with no placeholder shape, use `streaming-json`.
|
||||
|
||||
The stream opens with a `system`/`init` line, then `assistant` messages whose `message.content[]` holds `text`, `thinking`, and `tool_use` blocks, `user` messages carrying `tool_result` blocks, and a terminal `result`:
|
||||
|
||||
```json
|
||||
{"type":"system","subtype":"init","session_id":"abc123","apiKeySource":"user","model":"grok-build","cwd":"/repo","permissionMode":"default","tools":["read_file","bash"],"slash_commands":["review"],"mcp_servers":[{"name":"linear","status":"connected"}],"skills":[],"uuid":"..."}
|
||||
{"type":"assistant","message":{"id":"msg_0","type":"message","role":"assistant","model":"grok-build","content":[{"type":"text","text":"Let me read the file."},{"type":"tool_use","id":"call_1","name":"read_file","input":{"path":"src/main.rs"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{...}},"parent_tool_use_id":null,"session_id":"abc123","uuid":"..."}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"fn main() {}","is_error":false}]},"parent_tool_use_id":null,"session_id":"abc123","uuid":"..."}
|
||||
{"type":"result","subtype":"success","is_error":false,"duration_ms":0,"duration_api_ms":0,"num_turns":7,"result":"Here's a summary...","stop_reason":"end_turn","total_cost_usd":0.0127,"usage":{"input_tokens":812,"output_tokens":210,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"server_tool_use":{"web_search_requests":0}},"modelUsage":{},"session_id":"abc123","uuid":"..."}
|
||||
```
|
||||
|
||||
Message types:
|
||||
|
||||
| Type | Description |
|
||||
| ----------- | ---------------------------------------------------------------------- |
|
||||
| `system` | Session preamble (`subtype: "init"`) with model, cwd, permission mode, tools, slash commands, and MCP servers. `subtype: "compact_boundary"` marks an auto compaction |
|
||||
| `assistant` | A model message; `message.content[]` holds `text`/`thinking`/`tool_use`, plus `server_tool_use`/`web_search_tool_result` for inline backend web search |
|
||||
| `user` | Tool results, as `tool_result` blocks inside `message.content[]` |
|
||||
| `result` | Terminal message with final text, stop reason, and spend fields |
|
||||
|
||||
The `assistant` and `user` messages carry `session_id`, `uuid`, and `parent_tool_use_id` (`null` for the main conversation). The `system`/`init` and terminal `result` lines carry `session_id` and `uuid` but no `parent_tool_use_id`.
|
||||
|
||||
The `uuid` on each line is freshly generated per emitted line. It is not a provider, message, or event id, and not a correlation key. It does not match the provider `message.id` (that value rides `assistant.message.id`). It is unique per line, even for lines that describe the same message, and it carries no cross-line or cross-run identity. Do not use it to correlate or deduplicate.
|
||||
|
||||
Text and reasoning chunks are grouped into one assistant message per model response. A response's parallel `tool_result` blocks are grouped into a single `user` message. `result.result` is the final assistant message text. A model response that produces no content blocks emits no `assistant` line in the default mode. Only `--include-partial-messages` surfaces such a response, as its empty `message_start` … `message_stop` envelope.
|
||||
|
||||
On `init`, `skills` is live. It lists the session's user-invocable skill names, a subset of `slash_commands` sourced from the session's advertised commands, or `[]` when the session surfaces no skills. The `init` line is emitted once, deferred to the first output line so it captures the session's advertised `tools`, `slash_commands`, and `skills`. The Messages schema defines no second `init`, so a command list that changes after streaming begins is not re-advertised.
|
||||
|
||||
The other `init` fields carry real data:
|
||||
|
||||
- `apiKeySource` is `user` for API-key auth and `oauth` otherwise. Grok does not distinguish the schema's `project`, `org`, and `temporary` sources.
|
||||
- `permissionMode` is the effective headless mode mapped to the Messages enum: the `--permission-mode` value, or `bypassPermissions` under `--yolo`, else `default`. Grok-only modes such as `auto` collapse to `default`.
|
||||
- `mcp_servers[].status` reflects configuration, not live connection state. A configured server always reports `"connected"`, because per-server handshake state is not resolved by the time `init` is emitted.
|
||||
|
||||
Grok omits the schema's pure-placeholder `init` fields it has no data for, rather than emitting dummy values: `claude_code_version`, `output_style`, and `plugins`.
|
||||
|
||||
`result` includes `duration_ms`, `duration_api_ms`, `num_turns`, `stop_reason`, `total_cost_usd`, `usage` (Messages API `message.usage` shape), and `modelUsage`. It also includes `errors[]` on the error subtypes. Grok omits the schema's always-empty `permission_denials`, because it does not collect permission denials. `structured_output` (with `--json-schema`) is snake_case, matching the schema.
|
||||
|
||||
`model` appears on `init` and every `assistant` frame. It is the real model id when known, and the literal `"unknown"` only when no model is known at emit time.
|
||||
|
||||
The assistant frame's `stop_sequence` is wired end-to-end. It carries the provider's matched stop sequence when the model stopped on a configured one (`stop_reason: "stop_sequence"`), and is `null` on every other stop reason and backend. In `--include-partial-messages` framing, the matched sequence rides both the flushed `assistant` frame and the partial `message_delta.stop_sequence`, so a partial rebuild matches the frame. Only the partial `message_start.stop_sequence` stays `null`, because the matched sequence is not known at message open.
|
||||
|
||||
The emitted error subtypes are `error_max_turns`, `error_during_execution`, and `error_max_structured_output_retries`. The schema's `error_max_budget_usd` subtype is never emitted, because grok has no budget feature.
|
||||
|
||||
`result.usage` reports the Messages `message.usage` shape with the three token buckets disjoint: `input_tokens` (uncached), `cache_read_input_tokens`, and `cache_creation_input_tokens`. Grok derives these from the turn's aggregate ledger, reshaped into those buckets. Subagent cache creation is included in `cache_creation_input_tokens`. The aggregate ledger tracks it as its own bucket, so it is no longer folded into `input_tokens`.
|
||||
|
||||
`result.usage` always emits numeric buckets, even when data is missing. This happens when the turn's usage ledger is incomplete (the same condition that surfaces `usage_is_incomplete` in the `json` format), or when no aggregate ledger reached the reducer at all. Any bucket grok cannot account for falls back to `0`, because the Messages API schema has no marker for incomplete or absent usage. The reducer logs a warning to stderr in both cases. Read an all-zero `usage` here as "unknown", not "free".
|
||||
|
||||
The nested `server_tool_use` counter is populated. `web_search_requests` is the number of *successful* backend web searches emitted this run. Failed searches and non-search `WebSearch` actions such as open_page are excluded, matching the Messages API, which does not bill errored searches. A failed backend search still emits a `web_search_tool_result` in the error shape (`content.type: "web_search_tool_result_error"`), but is not counted. Its `error_code` is a fixed `"unavailable"` placeholder, not a code forwarded from the backend. There is no `web_fetch_requests` key, because grok has no server-side `web_fetch`, so the placeholder is omitted.
|
||||
|
||||
Backend web search is inline. It folds into the same `assistant` frame as the surrounding text. The frame carries a `server_tool_use` block (`name: "web_search"`, `input.query`) immediately followed by a `web_search_tool_result` block. That result block's `tool_use_id` matches the `server_tool_use.id`, and its `content` is a `web_search_result` hit array of `{type, url, title}`. This matches the Messages API's inline server-tool shape rather than splitting the response across frames.
|
||||
|
||||
X search and code interpreter are a documented divergence. They stay generic, surfaced as a client `tool_use` block plus a `user` `tool_result`, because the Messages API defines no inline block type for them. Every other client tool likewise keeps the `tool_use`/`tool_result` split.
|
||||
|
||||
`--include-partial-messages` emits the raw event framing so a consumer can rebuild each message with the Messages streaming accumulator. The framing is `message_start`, `content_block_start`/`content_block_delta`/`content_block_stop`, `message_delta`, and `message_stop`. It carries the structural events an accumulator needs. The deltas are coarser than the Messages API's token-level streaming: tool input arrives as a single `input_json_delta`, and `citations_delta` is never produced (see below). The result is a faithful reconstruction of each message rather than a token-by-token replay.
|
||||
|
||||
On the Messages API backend, the framing is faithful. `message_start` carries the real provider `message.id` and the input-side `usage`. A thinking block emits its `signature_delta` in order, before the block's `content_block_stop`. The `message_start.usage` input side reports all three prompt-side buckets known at message open: `input_tokens` (the uncached portion), `cache_read_input_tokens`, and `cache_creation_input_tokens`. A cache hit is therefore visible on `message_start`, rather than only appearing later on `message_delta`/`result`. `output_tokens` seeds `0` there and is finalized on `message_delta`. A response that starts but produces no content still emits the `message_start` … `message_stop` envelope with no content blocks.
|
||||
|
||||
Some backends surface per-response metadata only at end of turn. Those backends fall back to a synthesized `message_start.id` and zero-seeded input `usage`. They defer the reasoning `signature` to the final `assistant` line, which is authoritative in that case.
|
||||
|
||||
Tool-call input is emitted as a single `input_json_delta` carrying the complete arguments JSON, followed by `content_block_stop`. It is not a sequence of token-level fragments. This is a deliberate divergence from the Messages API's incremental `partial_json` streaming. Grok's ACP tool-call path delivers each tool call as one validated JSON object once the arguments are fully parsed, so a single delta is the accurate representation. A consumer that concatenates `partial_json` reassembles the identical object either way. The backend web-search `server_tool_use` block's `input.query` is emitted the same way, as one `input_json_delta`.
|
||||
|
||||
The Messages API `citations_delta` carries inline citations for cited text spans, such as those from web search. This stream does not produce it. Grok's Messages content deltas are limited to text, thinking, signature, and tool-input JSON, so there is no citation data to surface as a `citations_delta`. Backend web-search source URLs are reported inline on the completed `web_search_tool_result` block instead (see above), not as per-span text citations.
|
||||
|
||||
Fidelity caveats apply to a few fields.
|
||||
|
||||
`duration_ms` is the prompt-execution wall clock. `duration_api_ms` is the summed *reported* per-call model time. A model call that does not report its own duration contributes `0`, so `duration_api_ms` can under-count the true API time.
|
||||
|
||||
`num_turns` and `total_cost_usd` are authoritative when known. When they are not, `num_turns` falls back to the count of completed model responses this turn, and `total_cost_usd` falls back to `0`. A completed but contentless response emits no `assistant` line, yet still counts as a turn. Spend is never overreported.
|
||||
|
||||
`modelUsage` carries the per-model token and cost fields grok tracks, plus `webSearchRequests` attributed to the active model. The reducer tracks a single global web-search count rather than per-model, so the whole count lands on the current or last model and other rows stay `0`. A per-model `modelUsage.*.costUSD` is `0` when that model's cost is unknown or withheld. This is the same fail-closed-to-zero behavior as the top-level `total_cost_usd`. The `json` format omits cost floats entirely when partial, but this stream keeps the field present and `0`. `contextWindow` is the current model's real total context window (the same value grok uses for auto-compaction), and it appears only on the current model's row. Other rows omit it, and so does the current row when the window is unknown. `maxOutputTokens` has no grok catalog, so that key is omitted entirely. `modelUsage` is `{}` when no per-model breakdown is available.
|
||||
|
||||
Like `streaming-json`, this stream is read only. Tool approvals and other bidirectional flows use the ACP interface (`grok agent`).
|
||||
|
||||
---
|
||||
|
||||
## Session Management in Headless Mode
|
||||
|
|
@ -239,7 +332,7 @@ By default, each `grok -p` invocation creates a fresh session. To maintain conte
|
|||
|
||||
### Named Sessions (`-s`)
|
||||
|
||||
To carry context across headless calls, use `-r/--resume` or `-c/--continue`. Use `-s/--session-id` only for a **new** session with a **UUID** (errors if not a UUID or already in use under the target directory). Older hidden `-s` upsert/resume behavior is gone — use `-r`/`-c` to continue. With `-r`/`-c`, `-s` requires `--fork-session`:
|
||||
To carry context across headless calls, use `-r/--resume` or `-c/--continue`. Use `-s/--session-id` only for a **new** session with a **UUID** (errors if not a UUID or already in use under the target directory). Older hidden `-s` upsert/resume behavior is gone. Use `-r`/`-c` to continue. With `-r`/`-c`, `-s` requires `--fork-session`:
|
||||
|
||||
```bash
|
||||
# Start a headless session and capture its ID
|
||||
|
|
@ -256,7 +349,7 @@ grok -p "hello" --session-id "$(uuidgen | tr '[:upper:]' '[:lower:]')" --output-
|
|||
|
||||
### Resume (`-r`)
|
||||
|
||||
The `-r/--resume` flag resumes a specific session by ID, or by title for the current directory when the value is not an ID, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path — scripts should prefer IDs). It errors if the session does not exist:
|
||||
The `-r/--resume` flag resumes a specific session by ID, or by title for the current directory when the value is not an ID, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path, so scripts should prefer IDs). It errors if the session does not exist:
|
||||
|
||||
```bash
|
||||
# Get the session ID from a previous JSON response
|
||||
|
|
@ -467,8 +560,8 @@ grok -p "Run the test suite" --yolo
|
|||
|
||||
| Code | Meaning |
|
||||
| ---- | ------------------------------------ |
|
||||
| `0` | Success -- prompt completed normally |
|
||||
| `1` | Error -- authentication failure, network error, or runtime error |
|
||||
| `0` | Success. The prompt completed normally |
|
||||
| `1` | Error. Authentication failure, network error, or runtime error |
|
||||
| `130` | Interrupted by SIGINT (Ctrl+C) |
|
||||
| `143` | Terminated by SIGTERM |
|
||||
|
||||
|
|
@ -478,10 +571,10 @@ grok -p "Run the test suite" --yolo
|
|||
|
||||
For headless use, authenticate with one of:
|
||||
|
||||
- **`XAI_API_KEY`** — simplest for CI. See [Environment Variables](#environment-variables-for-headless) above.
|
||||
- **`grok login --device-auth`** (or `--device-code`) — no browser needed on the target machine.
|
||||
- **`XAI_API_KEY`**: simplest for CI. See [Environment Variables](#environment-variables-for-headless) above.
|
||||
- **`grok login --device-auth`** (or `--device-code`): no browser needed on the target machine.
|
||||
See [Authentication > Device Code Flow](02-authentication.md#device-code-flow).
|
||||
- **`grok login`** — browser-based OAuth2 on machines with a GUI.
|
||||
- **`grok login`**: browser-based OAuth2 on machines with a GUI.
|
||||
|
||||
If you've previously logged in, cached credentials are used automatically.
|
||||
|
||||
|
|
|
|||
|
|
@ -132,13 +132,14 @@ Alias: `/title`.
|
|||
|
||||
## The /rewind Command
|
||||
|
||||
`/rewind` undoes recent changes by restoring files to their state at an earlier point in the conversation. Use it to recover from mistakes.
|
||||
`/rewind` (alias `/undo`) undoes recent changes by restoring files to their state at an earlier point in the conversation. Use it to recover from mistakes.
|
||||
|
||||
```
|
||||
/rewind
|
||||
/undo
|
||||
```
|
||||
|
||||
When you run `/rewind` (or press **Esc Esc** within 800ms while idle with an empty prompt and conversation messages), Grok:
|
||||
When you run `/rewind` or `/undo` (or press **Esc Esc** within 800ms while idle with an empty prompt and conversation messages), Grok:
|
||||
|
||||
1. Shows a list of rewind points (one per user prompt)
|
||||
2. Lets you select which point to rewind to
|
||||
|
|
|
|||
|
|
@ -192,9 +192,10 @@ pub async fn spawn_grok_shell(
|
|||
// here, so the agent's external-OTEL gate is applied exactly once, before boot.
|
||||
xai_grok_shell::agent::app::apply_otel_config(&auth_manager, &agent_config.grok_com_config);
|
||||
|
||||
// Best-effort refresh of managed policy before bootstrap reads it (repairs a wrong-identity/missing
|
||||
// cache). Never errors — the OS-protected system/MDM layers still apply, and every network step
|
||||
// inside is bounded (SESSION_START_AUTH_DEADLINE / SyncBudget::SessionStart).
|
||||
// Best-effort refresh of managed policy before bootstrap reads it (repairs a
|
||||
// wrong-identity/missing cache). Never errors — the OS-protected system/MDM
|
||||
// layers still apply, and every network step inside is bounded
|
||||
// (SESSION_START_AUTH_DEADLINE / SyncBudget::SessionStart).
|
||||
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
||||
|
||||
// Run the full bootstrap sequence: config resolution, process-level
|
||||
|
|
|
|||
|
|
@ -93,11 +93,16 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option<i64>) -> std::time::Insta
|
|||
/// when silent (the user's standing instruction stopped executing invisibly).
|
||||
/// Silent rate limits defer to the retry notifications, like the real-turn
|
||||
/// rails.
|
||||
///
|
||||
/// `cancel_trigger` is the signal's `_meta.cancelTrigger`. `"send_now"` marks
|
||||
/// an internal cancel-and-send, so the `TurnCancelled` marker is suppressed
|
||||
/// (wire trigger wins; `expect_send_now_cancel` is the older-shell fallback).
|
||||
pub(super) fn finish_wake_turn(
|
||||
agent: &mut AgentView,
|
||||
prompt_id: &str,
|
||||
stop_reason: &str,
|
||||
agent_result: Option<&str>,
|
||||
cancel_trigger: Option<&str>,
|
||||
) {
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
|
|
@ -117,6 +122,11 @@ pub(super) fn finish_wake_turn(
|
|||
} else {
|
||||
None
|
||||
};
|
||||
// Wire trigger carries this case; pid-matched fallback is consistency-only (do not take/clear).
|
||||
let send_now_cancel = match cancel_trigger {
|
||||
Some(trigger) => trigger == "send_now",
|
||||
None => agent.expect_send_now_cancel.as_deref() == Some(prompt_id),
|
||||
};
|
||||
let already_failed = agent.failed_wake_marker_for.as_deref() == Some(prompt_id);
|
||||
let event = match stop_reason {
|
||||
"error" | "rate_limit"
|
||||
|
|
@ -138,6 +148,8 @@ pub(super) fn finish_wake_turn(
|
|||
})
|
||||
}
|
||||
"cancelled" if !had_output => None,
|
||||
// Send-now cancel: no marker (the sender's new prompt is the next turn).
|
||||
"cancelled" if send_now_cancel => None,
|
||||
"cancelled" => Some(SessionEvent::TurnCancelled {
|
||||
elapsed: elapsed.unwrap_or_default(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -244,7 +244,18 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
false
|
||||
}
|
||||
} else {
|
||||
finish_wake_turn(agent, &prompt_id, &stop_reason, agent_result.as_deref());
|
||||
let cancel_trigger = session_notif
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("cancelTrigger"))
|
||||
.and_then(|v| v.as_str());
|
||||
finish_wake_turn(
|
||||
agent,
|
||||
&prompt_id,
|
||||
&stop_reason,
|
||||
agent_result.as_deref(),
|
||||
cancel_trigger,
|
||||
);
|
||||
true
|
||||
}
|
||||
} else if is_server_initiated_prompt(&prompt_id)
|
||||
|
|
@ -401,6 +412,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
child_view.active_pane = crate::views::agent::ActivePane::Scrollback;
|
||||
child_view.set_sharing_enabled(agent.sharing_enabled);
|
||||
child_view.set_billing_surface_visible(agent.billing_surface_visible);
|
||||
child_view.set_usage_command_visible(agent.usage_command_visible);
|
||||
let dashboard_visible = agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
|
|
|
|||
|
|
@ -121,12 +121,13 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
if let Some(v) = update.show_resolved_model {
|
||||
app.show_resolved_model = v;
|
||||
}
|
||||
if let Some(v) = update.sharing_enabled {
|
||||
app.sharing_enabled = v;
|
||||
// Propagate to existing agents so slash-command registries stay
|
||||
// in sync (same fan-out pattern used when creating new agents).
|
||||
// Temporary client kill switch: ignore remote `sharing_enabled` until
|
||||
// session share links are restored. Presence is still observed so a
|
||||
// later re-enable can go back to `app.sharing_enabled = v`.
|
||||
if update.sharing_enabled.is_some() {
|
||||
app.sharing_enabled = false;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.set_sharing_enabled(v);
|
||||
agent.set_sharing_enabled(false);
|
||||
}
|
||||
}
|
||||
// Env overrides win over live updates too, mirroring the startup
|
||||
|
|
@ -152,7 +153,7 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
let was_api_key = app.is_api_key_auth;
|
||||
let is_key = super::super::app_view::is_api_key_label(&v);
|
||||
app.is_api_key_auth = is_key;
|
||||
app.usage_visible = !is_key && app.team_name.is_none();
|
||||
app.usage_visible = !is_key && app.team_name.is_none() && !app.has_external_auth_provider;
|
||||
app.sync_billing_surface_to_agents();
|
||||
app.subscription_tier = Some(v);
|
||||
app.apply_tier_restrictions();
|
||||
|
|
|
|||
|
|
@ -988,6 +988,33 @@ pub(super) fn xai_turn_completed_notif(
|
|||
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
|
||||
)
|
||||
}
|
||||
/// Live `TurnCompleted` stamped with `_meta.cancelTrigger` (send-now / ctrl_c).
|
||||
pub(super) fn xai_turn_completed_notif_with_cancel_trigger(
|
||||
session_id: &str,
|
||||
prompt_id: &str,
|
||||
stop_reason: &str,
|
||||
cancel_trigger: &str,
|
||||
) -> acp::ExtNotification {
|
||||
let payload = SessionNotification {
|
||||
session_id: acp::SessionId::new(session_id),
|
||||
update: XaiSessionUpdate::TurnCompleted {
|
||||
prompt_id: prompt_id.into(),
|
||||
stop_reason: stop_reason.into(),
|
||||
agent_result: None,
|
||||
usage: None,
|
||||
},
|
||||
meta: Some(
|
||||
serde_json::json!({
|
||||
"isReplay": false,
|
||||
"cancelTrigger": cancel_trigger,
|
||||
}),
|
||||
),
|
||||
};
|
||||
acp::ExtNotification::new(
|
||||
"x.ai/session/update",
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
|
||||
)
|
||||
}
|
||||
/// A live durable `TurnCompleted`, optionally stamped with the shell
|
||||
/// completion clock (`agentTimestampMs`) the wake marker's elapsed reads.
|
||||
pub(super) fn xai_wake_turn_completed_notif(
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
agent.prompt.slash_controller.screen_mode(),
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
|
|||
|
|
@ -427,6 +427,13 @@
|
|||
Some("legacy_auth"),
|
||||
"Unauthorized (401) ... deprecated authentication method"
|
||||
));
|
||||
// auth_transient = the shell says the failure self-heals (refreshable
|
||||
// credential, no sticky verdict — e.g. post-wake network gap). Even
|
||||
// with a 401 in the message, the `/login` banner must not fire.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("auth_transient"),
|
||||
"Unauthorized (401)\n\nAuthentication is temporarily unavailable"
|
||||
));
|
||||
// Unrelated failures must not be treated as re-authable.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("server_error"),
|
||||
|
|
|
|||
|
|
@ -565,7 +565,7 @@
|
|||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sharing_enabled": true,
|
||||
"show_resolved_model": false,
|
||||
"announcements": [critical_announcement("from-settings")],
|
||||
}))
|
||||
.unwrap()
|
||||
|
|
@ -579,7 +579,46 @@
|
|||
"settings/update must not replace the pushed announcements"
|
||||
);
|
||||
assert_eq!(app.announcements_last_gen, 7, "watermark untouched");
|
||||
assert!(app.sharing_enabled, "other settings fields still apply");
|
||||
assert!(!app.show_resolved_model, "other settings fields still apply");
|
||||
}
|
||||
|
||||
/// Temporary client kill switch: remote `sharing_enabled: true` must not
|
||||
/// re-enable share UI. Agents stay off and `/share` stays menu-hidden
|
||||
/// (typed `/share` still dispatches for the disable message).
|
||||
#[test]
|
||||
fn settings_update_sharing_enabled_true_stays_forced_off() {
|
||||
let mut app = make_app_with_agent("sess-share-kill");
|
||||
app.sharing_enabled = true;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.set_sharing_enabled(true);
|
||||
}
|
||||
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sharing_enabled": true,
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
!app.sharing_enabled,
|
||||
"remote true must not lift the temporary kill switch"
|
||||
);
|
||||
for agent in app.agents.values() {
|
||||
assert!(!agent.sharing_enabled);
|
||||
let reg = agent.prompt.slash_controller.registry();
|
||||
assert!(
|
||||
reg.get("share").is_none(),
|
||||
"/share stays out of the completion menu"
|
||||
);
|
||||
assert!(
|
||||
reg.get_for_dispatch("share").is_some(),
|
||||
"typed /share still resolves so the disable path can run"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// User-owned mode must not re-arm default_yolo or rewrite UI from remote.
|
||||
|
|
|
|||
|
|
@ -609,6 +609,97 @@
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_send_now_cancelled_wake_is_markerless() {
|
||||
// A wake with output cancelled by send-now must stay silent — same
|
||||
// suppression the other three turn-end rails already apply.
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif_with_cancel_trigger(
|
||||
"sess-wake",
|
||||
"task-completed-bg1",
|
||||
"cancelled",
|
||||
"send_now",
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
len_before,
|
||||
"a send-now cancelled chatty wake must push no marker"
|
||||
);
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
assert!(
|
||||
!matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCancelled { .. })
|
||||
),
|
||||
"send_now must not surface as Turn cancelled by user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_user_cancelled_wake_pushes_cancelled_marker() {
|
||||
// Genuine cancel (Ctrl+C / Esc, no wire trigger) still shows the marker.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "cancelled", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCancelled { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_send_now_arm_does_not_suppress_wake_cancel_marker() {
|
||||
// A flag armed for a different (user) prompt must not eat this wake's
|
||||
// genuine cancel marker, and must stay armed after close-out.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.expect_send_now_cancel = Some("user-prompt-other".into());
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "cancelled", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCancelled { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
agent.expect_send_now_cancel.as_deref(),
|
||||
Some("user-prompt-other"),
|
||||
"wake close-out must not clear a foreign send-now arm"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_rate_limited_wake_closes_with_failure_marker() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
|
|
|
|||
|
|
@ -1201,7 +1201,7 @@ impl AgentView {
|
|||
self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
self.sharing_enabled,
|
||||
self.prompt.slash_controller.screen_mode(),
|
||||
&self.prompt.slash_controller,
|
||||
),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
@ -1323,7 +1323,7 @@ impl AgentView {
|
|||
self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
self.sharing_enabled,
|
||||
self.prompt.slash_controller.screen_mode(),
|
||||
&self.prompt.slash_controller,
|
||||
),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
@ -2111,7 +2111,7 @@ mod focus_gained_restore_tests {
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
false,
|
||||
agent.prompt.slash_controller.screen_mode(),
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
|
|||
|
|
@ -1399,6 +1399,8 @@ pub struct AgentView {
|
|||
pub scheduler_background_loops: Option<bool>,
|
||||
/// Mirrors `AppView::usage_visible` (credit warning + `/usage manage`).
|
||||
pub billing_surface_visible: bool,
|
||||
/// Whether `/usage` is offered. Mirrors `!AppView::has_external_auth_provider`.
|
||||
pub usage_command_visible: bool,
|
||||
/// Input flight recorder — rolling buffer of recent key events.
|
||||
/// Dumped to file via Esc→d combo for debugging.
|
||||
pub(crate) input_log: crate::input_log::InputRingBuffer,
|
||||
|
|
@ -2019,6 +2021,10 @@ pub(super) fn apply_settings_outcome(
|
|||
}
|
||||
SettingsKeyOutcome::Action(a) => InputOutcome::Action(a),
|
||||
SettingsKeyOutcome::ActionPair(a, b) => InputOutcome::ActionPair(a, b),
|
||||
SettingsKeyOutcome::ActionThenClose(a) => {
|
||||
agent.active_modal = None;
|
||||
InputOutcome::Action(a)
|
||||
}
|
||||
SettingsKeyOutcome::Changed => InputOutcome::Changed,
|
||||
SettingsKeyOutcome::Unchanged => InputOutcome::Unchanged,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ impl AgentView {
|
|||
/// is replaced; [`Self::sticky_toast`] is preserved and returns after this
|
||||
/// expires or is dismissed.
|
||||
pub fn show_toast(&mut self, msg: &str) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, 90));
|
||||
self.toast = Some((crate::glyphs::sanitize_toast_message(msg).into_owned(), 90));
|
||||
}
|
||||
|
||||
/// Show an ephemeral tip in the banner row above the prompt, gated by the
|
||||
|
|
@ -216,7 +215,7 @@ impl AgentView {
|
|||
/// Set or clear the sticky status banner (process-wide indicators should
|
||||
/// use [`Self::set_sticky_toast_recursive`] on every agent view).
|
||||
pub fn set_sticky_toast(&mut self, msg: Option<&str>) {
|
||||
self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned());
|
||||
self.sticky_toast = msg.map(|m| crate::glyphs::sanitize_toast_message(m).into_owned());
|
||||
}
|
||||
|
||||
/// Propagate sticky status to this view and every nested subagent view.
|
||||
|
|
@ -229,8 +228,10 @@ impl AgentView {
|
|||
|
||||
/// Show a toast with an explicit tick duration.
|
||||
pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, ticks));
|
||||
self.toast = Some((
|
||||
crate::glyphs::sanitize_toast_message(msg).into_owned(),
|
||||
ticks,
|
||||
));
|
||||
}
|
||||
|
||||
/// Message currently drawn in the toast slot: transient wins while active,
|
||||
|
|
@ -401,4 +402,38 @@ mod mouse_off_banner_tests {
|
|||
view.active_pane = AgentPane::Prompt;
|
||||
assert_eq!(view.active_toast_message(), Some("Reconnecting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_toast_scrubs_control_chars() {
|
||||
let mut view = make_running_agent();
|
||||
view.show_toast("a\nb\rc\thttps://x.ai");
|
||||
let msg = view.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or("");
|
||||
assert!(
|
||||
!msg.chars().any(char::is_control),
|
||||
"show_toast must scrub controls: {msg:?}"
|
||||
);
|
||||
assert!(msg.contains("https://x.ai"), "{msg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_toast_ticks_scrubs_control_chars() {
|
||||
let mut view = make_running_agent();
|
||||
view.show_toast_ticks("x\ny\tz", 10);
|
||||
let msg = view.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or("");
|
||||
assert!(
|
||||
!msg.chars().any(char::is_control),
|
||||
"show_toast_ticks must scrub controls: {msg:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_sticky_toast_scrubs_control_chars() {
|
||||
let mut view = make_running_agent();
|
||||
view.set_sticky_toast(Some("sticky\nline"));
|
||||
let msg = view.sticky_toast.as_deref().unwrap_or("");
|
||||
assert!(
|
||||
!msg.chars().any(char::is_control),
|
||||
"sticky toast must scrub controls: {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,6 +353,18 @@ impl AgentView {
|
|||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if !is_commenting
|
||||
&& key.code == KeyCode::Char('a')
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.prompt.text().trim().is_empty()
|
||||
&& !self.prompt.file_search_visible()
|
||||
&& self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.is_some_and(|pav| pav.comments.is_empty())
|
||||
{
|
||||
return self.approve_plan();
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
||||
EnterOutcome::Submit => {
|
||||
|
|
@ -370,7 +382,8 @@ impl AgentView {
|
|||
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Prompt);
|
||||
if prompt_focused {
|
||||
if text.trim().is_empty() && !has_comments {
|
||||
return self.approve_plan();
|
||||
self.show_toast("Type revision notes, or press a to approve.");
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let freeform = if text.trim().is_empty() {
|
||||
None
|
||||
|
|
@ -872,3 +885,111 @@ mod plan_chip_tests {
|
|||
));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod plan_approval_enter_tests {
|
||||
use super::test_fixtures::make_agent;
|
||||
use super::*;
|
||||
use crate::views::plan_approval_view::PlanApprovalFocus;
|
||||
fn enter_key() -> KeyEvent {
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||
}
|
||||
fn agent_with_revise_prompt() -> AgentView {
|
||||
let mut agent = make_agent();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let request = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "test-session".into(),
|
||||
tool_call_id: "call-1".into(),
|
||||
plan_content: Some("# Plan\n\n## Step 1\nDo something".into()),
|
||||
};
|
||||
let mut pav = crate::views::plan_approval_view::PlanApprovalViewState::new(
|
||||
request,
|
||||
crate::views::prompt_widget::StashedPrompt {
|
||||
text: String::new(),
|
||||
cursor: 0,
|
||||
images: Vec::new(),
|
||||
chip_elements: Vec::new(),
|
||||
image_counter: 0,
|
||||
image_undo_stash: Vec::new(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
pav.focus = PlanApprovalFocus::Prompt;
|
||||
agent.plan_approval_view = Some(pav);
|
||||
agent.prompt.set_text("");
|
||||
agent
|
||||
}
|
||||
#[test]
|
||||
fn empty_enter_on_revise_prompt_does_not_approve() {
|
||||
let mut agent = agent_with_revise_prompt();
|
||||
let outcome = agent.handle_plan_feedback_key(&enter_key());
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"empty Enter must leave plan approval open"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(msg, _)| msg.as_str()),
|
||||
Some("Type revision notes, or press a to approve.")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn enter_with_revision_text_requests_changes() {
|
||||
let mut agent = agent_with_revise_prompt();
|
||||
agent.prompt.set_text("please use auth middleware");
|
||||
let outcome = agent.handle_plan_feedback_key(&enter_key());
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.plan_approval_view.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(msg, _)| msg.as_str()),
|
||||
Some("Plan revision sent.")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn empty_enter_with_pending_comments_still_requests_changes() {
|
||||
let mut agent = agent_with_revise_prompt();
|
||||
if let Some(ref mut pav) = agent.plan_approval_view {
|
||||
pav.comments.push(PlanComment {
|
||||
id: 1,
|
||||
line_range: 0..1,
|
||||
text: "nit".into(),
|
||||
});
|
||||
}
|
||||
let outcome = agent.handle_plan_feedback_key(&enter_key());
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.plan_approval_view.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(msg, _)| msg.as_str()),
|
||||
Some("Plan revision sent.")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_on_empty_revise_prompt_approves() {
|
||||
let mut agent = agent_with_revise_prompt();
|
||||
let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
|
||||
let outcome = agent.handle_plan_feedback_key(&a);
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(agent.plan_approval_view.is_none(), "`a` must approve");
|
||||
assert_ne!(
|
||||
agent.toast.as_ref().map(|(msg, _)| msg.as_str()),
|
||||
Some("Plan revision sent.")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_with_pending_comments_does_not_approve() {
|
||||
let mut agent = agent_with_revise_prompt();
|
||||
if let Some(ref mut pav) = agent.plan_approval_view {
|
||||
pav.comments.push(PlanComment {
|
||||
id: 1,
|
||||
line_range: 0..1,
|
||||
text: "nit".into(),
|
||||
});
|
||||
}
|
||||
let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
|
||||
let _ = agent.handle_plan_feedback_key(&a);
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"`a` with pending comments must type, not approve"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), "a");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ impl AgentView {
|
|||
]
|
||||
} else {
|
||||
vec![
|
||||
HintItem::new(key!(Enter), "approve"),
|
||||
HintItem::new(key!('a'), "approve"),
|
||||
HintItem::new(key!(Tab), "plan"),
|
||||
HintItem::new(key!(Esc), "back"),
|
||||
]
|
||||
|
|
@ -698,6 +698,7 @@ impl AgentView {
|
|||
voice_interim,
|
||||
esc_owned_before_agent,
|
||||
} = app_params;
|
||||
self.scrollback.begin_frame();
|
||||
self.in_dashboard_overlay = in_dashboard_overlay;
|
||||
let super::BannerSlotParams {
|
||||
height: banner_height,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ impl AgentView {
|
|||
sharing_enabled: false,
|
||||
scheduler_background_loops: None,
|
||||
billing_surface_visible: false,
|
||||
usage_command_visible: true,
|
||||
input_log: crate::input_log::InputRingBuffer::new(),
|
||||
esc_pressed_at: None,
|
||||
rewind_suppress_deadline: None,
|
||||
|
|
@ -918,6 +919,12 @@ impl AgentView {
|
|||
.slash_controller
|
||||
.set_billing_surface_visible(visible);
|
||||
}
|
||||
pub fn set_usage_command_visible(&mut self, visible: bool) {
|
||||
self.usage_command_visible = visible;
|
||||
self.prompt
|
||||
.slash_controller
|
||||
.set_usage_command_visible(visible);
|
||||
}
|
||||
/// Replace the restricted slash-command deny list in this agent's
|
||||
/// registry (e.g. `/usage` denied on the free / X Basic tiers). Deny
|
||||
/// wins over every `set_*_visible` gate.
|
||||
|
|
@ -945,6 +952,7 @@ impl AgentView {
|
|||
&mut self,
|
||||
sharing_enabled: bool,
|
||||
billing_surface_visible: bool,
|
||||
usage_command_visible: bool,
|
||||
chat_mode: bool,
|
||||
screen_mode: crate::app::ScreenMode,
|
||||
announcements: &[xai_grok_announcements::RemoteAnnouncement],
|
||||
|
|
@ -952,6 +960,7 @@ impl AgentView {
|
|||
) {
|
||||
self.set_sharing_enabled(sharing_enabled);
|
||||
self.set_billing_surface_visible(billing_surface_visible);
|
||||
self.set_usage_command_visible(usage_command_visible);
|
||||
self.app_chat_mode = chat_mode;
|
||||
self.prompt.set_screen_mode(screen_mode);
|
||||
self.set_dashboard_visible(crate::views::dashboard::dashboard_enabled());
|
||||
|
|
|
|||
|
|
@ -687,16 +687,19 @@ pub struct AppView {
|
|||
pub tip: Option<String>,
|
||||
/// Whether to show the resolved model ID in /session-info output.
|
||||
pub show_resolved_model: bool,
|
||||
/// Whether the `/share` slash command is available. Gated by
|
||||
/// `RemoteSettings.sharing_enabled`; defaults to `false` when remote
|
||||
/// settings are unavailable or the field is absent.
|
||||
/// Whether the `/share` slash command is available. Currently forced off
|
||||
/// while session share links are temporarily disabled in clients.
|
||||
pub sharing_enabled: bool,
|
||||
/// Whether the plugin marketplace CTA is enabled. Env `GROK_PLUGIN_CTA`
|
||||
/// overrides `RemoteSettings.plugin_cta` (remote settings); defaults to `false`.
|
||||
pub plugin_cta_enabled: bool,
|
||||
/// Consumer billing surface (credit fetches / warnings). False for team
|
||||
/// and API-key auth. `/usage` itself stays available for session token/cost.
|
||||
/// and API-key auth. `/usage` itself stays available for session token/cost
|
||||
/// unless [`Self::has_external_auth_provider`].
|
||||
pub usage_visible: bool,
|
||||
/// External `auth_provider_command` deployment.
|
||||
/// No grok.com billing session exists; `/usage` and credit UI stay off.
|
||||
pub has_external_auth_provider: bool,
|
||||
/// Slash commands denied for the current subscription tier
|
||||
/// ([`TIER_RESTRICTED_COMMANDS`] when the user is on the free / X Basic
|
||||
/// tier, empty otherwise). Recomputed by [`Self::apply_tier_restrictions`]
|
||||
|
|
@ -1197,47 +1200,6 @@ fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option<u64>) -> bo
|
|||
};
|
||||
chrono::Utc::now() >= next
|
||||
}
|
||||
/// Welcome-screen toast overlay (mirrors agent toast style).
|
||||
///
|
||||
/// Prefer one row above the prompt, right-aligned to it. Fall back to
|
||||
/// the view bottom-right when no prompt rect is available (login / gate).
|
||||
fn paint_welcome_toast(
|
||||
buf: &mut ratatui::buffer::Buffer,
|
||||
area: ratatui::layout::Rect,
|
||||
msg: &str,
|
||||
prompt_rect: Option<ratatui::layout::Rect>,
|
||||
) {
|
||||
let theme = crate::theme::Theme::current();
|
||||
let max_msg = (area.width as usize).saturating_sub(4);
|
||||
if max_msg == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let toast = if msg.chars().count() <= max_msg {
|
||||
format!(" {msg} ")
|
||||
} else {
|
||||
let truncated: String = msg.chars().take(max_msg.saturating_sub(1)).collect();
|
||||
format!(" {}… ", truncated.trim_end())
|
||||
};
|
||||
let w = toast.chars().count() as u16;
|
||||
let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) {
|
||||
let max_x = area.right().saturating_sub(w).max(area.x);
|
||||
let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x);
|
||||
(x, prompt.y.saturating_sub(1))
|
||||
} else {
|
||||
(
|
||||
area.right().saturating_sub(w + 1),
|
||||
area.bottom().saturating_sub(1),
|
||||
)
|
||||
};
|
||||
for (i, ch) in toast.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AppView {
|
||||
pub fn is_zdr_blocked(&self) -> bool {
|
||||
self.is_zdr && !self.zdr_access_enabled
|
||||
|
|
@ -1344,7 +1306,8 @@ impl AppView {
|
|||
.subscription_tier
|
||||
.as_deref()
|
||||
.is_some_and(is_api_key_label);
|
||||
self.usage_visible = meta.team_name.is_none() && !self.is_api_key_auth;
|
||||
self.usage_visible =
|
||||
meta.team_name.is_none() && !self.is_api_key_auth && !self.has_external_auth_provider;
|
||||
self.sync_billing_surface_to_agents();
|
||||
self.apply_tier_restrictions();
|
||||
if self.is_api_key_auth {
|
||||
|
|
@ -1358,23 +1321,34 @@ impl AppView {
|
|||
self.show_resolved_model = show;
|
||||
}
|
||||
}
|
||||
/// Mirror [`Self::usage_visible`] onto every slash surface that can run
|
||||
/// `/usage` (agents, welcome, dashboard dispatch / peek-reply).
|
||||
/// Mirror billing + `/usage` gates onto every slash surface (agents,
|
||||
/// welcome, dashboard dispatch / peek-reply).
|
||||
pub(crate) fn sync_billing_surface_to_agents(&mut self) {
|
||||
let visible = self.usage_visible;
|
||||
let billing = self.usage_visible;
|
||||
let usage_cmd = !self.has_external_auth_provider;
|
||||
for agent in self.agents.values_mut() {
|
||||
agent.set_billing_surface_visible(visible);
|
||||
agent.set_billing_surface_visible(billing);
|
||||
agent.set_usage_command_visible(usage_cmd);
|
||||
}
|
||||
self.welcome_prompt
|
||||
.slash_controller
|
||||
.set_billing_surface_visible(visible);
|
||||
.set_billing_surface_visible(billing);
|
||||
self.welcome_prompt
|
||||
.slash_controller
|
||||
.set_usage_command_visible(usage_cmd);
|
||||
if let Some(dash) = self.dashboard.as_mut() {
|
||||
dash.dispatch
|
||||
.slash_controller
|
||||
.set_billing_surface_visible(visible);
|
||||
.set_billing_surface_visible(billing);
|
||||
dash.dispatch
|
||||
.slash_controller
|
||||
.set_usage_command_visible(usage_cmd);
|
||||
dash.peek_reply
|
||||
.slash_controller
|
||||
.set_billing_surface_visible(visible);
|
||||
.set_billing_surface_visible(billing);
|
||||
dash.peek_reply
|
||||
.slash_controller
|
||||
.set_usage_command_visible(usage_cmd);
|
||||
}
|
||||
}
|
||||
/// Force voice on for API-key sessions when only a remote rule left it off.
|
||||
|
|
@ -1574,6 +1548,7 @@ impl AppView {
|
|||
sharing_enabled: false,
|
||||
plugin_cta_enabled: false,
|
||||
usage_visible: true,
|
||||
has_external_auth_provider: false,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: false,
|
||||
credit_balance: None,
|
||||
|
|
@ -1681,6 +1656,7 @@ impl AppView {
|
|||
pub fn apply_tier_restrictions(&mut self) {
|
||||
let restricted = self.team_name.is_none()
|
||||
&& !self.is_api_key_auth
|
||||
&& !self.has_external_auth_provider
|
||||
&& is_restricted_tier(self.subscription_tier.as_deref());
|
||||
let names: Vec<String> = if restricted {
|
||||
TIER_RESTRICTED_COMMANDS
|
||||
|
|
@ -1997,13 +1973,14 @@ impl AppView {
|
|||
}
|
||||
ActiveView::AgentDashboard => {
|
||||
if let Some(d) = self.dashboard.as_mut() {
|
||||
d.error_toast = Some(crate::glyphs::legacy_glyph_fallback(msg).into_owned());
|
||||
d.error_toast = Some(crate::glyphs::sanitize_toast_message(msg).into_owned());
|
||||
}
|
||||
}
|
||||
ActiveView::Welcome => {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.welcome_toast =
|
||||
Some((msg, std::time::Instant::now() + WELCOME_TOAST_DURATION));
|
||||
self.welcome_toast = Some((
|
||||
crate::glyphs::sanitize_toast_message(msg).into_owned(),
|
||||
std::time::Instant::now() + WELCOME_TOAST_DURATION,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4389,7 +4366,7 @@ impl AppView {
|
|||
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
|
||||
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
|
||||
if let Some((ref msg, _)) = self.welcome_toast {
|
||||
paint_welcome_toast(
|
||||
crate::views::welcome::paint_welcome_toast(
|
||||
f.buffer_mut(),
|
||||
view_area,
|
||||
msg,
|
||||
|
|
@ -5624,6 +5601,22 @@ pub(crate) mod tests {
|
|||
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
#[test]
|
||||
fn welcome_show_toast_scrubs_control_chars() {
|
||||
let mut app = test_app();
|
||||
assert!(matches!(app.active_view, ActiveView::Welcome));
|
||||
app.show_toast("a\nb\rc\thttps://x.ai");
|
||||
let toast = app
|
||||
.welcome_toast
|
||||
.as_ref()
|
||||
.map(|(m, _)| m.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
!toast.chars().any(|c| c.is_control()),
|
||||
"control chars must be scrubbed at write: {toast:?}"
|
||||
);
|
||||
assert!(toast.contains("https://x.ai"), "{toast:?}");
|
||||
}
|
||||
#[test]
|
||||
fn parse_esc_ttl_bounds() {
|
||||
let default = PendingAction::ESC_DOUBLE_PRESS_TTL;
|
||||
assert_eq!(parse_esc_ttl(None), default);
|
||||
|
|
@ -5865,6 +5858,7 @@ pub(crate) mod tests {
|
|||
sharing_enabled: false,
|
||||
plugin_cta_enabled: false,
|
||||
usage_visible: true,
|
||||
has_external_auth_provider: false,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
|
|
@ -7049,6 +7043,22 @@ pub(crate) mod tests {
|
|||
assert_eq!(counts.get("t_seen"), Some(&2));
|
||||
}
|
||||
#[test]
|
||||
fn external_auth_provider_keeps_billing_off_after_auth_meta() {
|
||||
let mut app = test_app();
|
||||
app.has_external_auth_provider = true;
|
||||
app.usage_visible = false;
|
||||
app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default());
|
||||
assert!(!app.usage_visible);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
assert!(
|
||||
!app.welcome_prompt
|
||||
.slash_controller
|
||||
.registry()
|
||||
.is_restricted("usage")
|
||||
);
|
||||
assert!(!app.welcome_prompt.slash_controller.usage_command_visible());
|
||||
}
|
||||
#[test]
|
||||
fn apply_auth_meta_disables_billing_surface_for_team_users() {
|
||||
let mut app = test_app();
|
||||
assert!(app.usage_visible);
|
||||
|
|
|
|||
|
|
@ -502,6 +502,10 @@ pub struct PagerArgs {
|
|||
/// Output format for headless mode.
|
||||
#[clap(long = "output-format", value_enum, default_value = "plain")]
|
||||
pub output_format: OutputFormat,
|
||||
/// Emit incremental `stream_event` lines (text/thinking deltas) alongside
|
||||
/// whole messages. Only affects `--output-format streaming-messages-json`.
|
||||
#[clap(long = "include-partial-messages")]
|
||||
pub include_partial_messages: bool,
|
||||
/// JSON Schema for structured output. When set, the model is constrained to
|
||||
/// produce JSON matching this schema. Implies --output-format json.
|
||||
/// Example: --json-schema '{"type":"object","properties":{"name":{"type":"string"}}}'
|
||||
|
|
|
|||
|
|
@ -46,15 +46,14 @@ pub(super) fn open_url_or_show(app: &mut AppView, url: &str) {
|
|||
return;
|
||||
}
|
||||
|
||||
use crate::app::link_opener::{OpenUrlResult, browser_unavailable_message, try_open_url};
|
||||
use crate::app::link_opener::{OpenUrlResult, browser_unavailable_line, try_open_url};
|
||||
use crate::terminal::hyperlinks::SchemeFilter;
|
||||
|
||||
match try_open_url(url, SchemeFilter::Standard) {
|
||||
OpenUrlResult::Opened | OpenUrlResult::RejectedScheme => {}
|
||||
OpenUrlResult::BrowserUnavailable => {
|
||||
let _ = crate::clipboard::SystemClipboard::try_set(url);
|
||||
// No scrollback on the welcome screen — toast carries the URL.
|
||||
app.show_toast(&browser_unavailable_message(url));
|
||||
let copied = crate::clipboard::SystemClipboard::try_set(url).reported_success();
|
||||
app.show_toast(&browser_unavailable_line(url, copied));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,14 +56,23 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) {
|
|||
state.set_voice_visible(app.voice_mode_enabled);
|
||||
state.set_restricted_commands(&app.tier_restricted_commands);
|
||||
let billing = app.usage_visible;
|
||||
let usage_cmd = !app.has_external_auth_provider;
|
||||
state
|
||||
.dispatch
|
||||
.slash_controller
|
||||
.set_billing_surface_visible(billing);
|
||||
state
|
||||
.dispatch
|
||||
.slash_controller
|
||||
.set_usage_command_visible(usage_cmd);
|
||||
state
|
||||
.peek_reply
|
||||
.slash_controller
|
||||
.set_billing_surface_visible(billing);
|
||||
state
|
||||
.peek_reply
|
||||
.slash_controller
|
||||
.set_usage_command_visible(usage_cmd);
|
||||
app.dashboard = Some(state);
|
||||
}
|
||||
|
||||
|
|
@ -1270,9 +1279,10 @@ pub(super) fn dispatch_dashboard_dispatch(
|
|||
///
|
||||
/// Offer / execute tri-state (matches completion's [`command_offered`]):
|
||||
/// - **Unknown** token → [`dispatch_dashboard_dispatch`] (new session prompt).
|
||||
/// - **Registered, not offered** (session-scoped hidden on this surface,
|
||||
/// or `dashboard_only` off-dashboard) → clear dispatch + error toast;
|
||||
/// do **not** spawn with the slash text as the prompt.
|
||||
/// - **Registered, session-scoped** (hidden on this surface) → clear
|
||||
/// dispatch + error toast; do **not** spawn with the slash as a prompt.
|
||||
/// - **Registered, not visible** (auth/feature gate, e.g. `/usage` on
|
||||
/// external auth) → still `command.run` so the command owns the error.
|
||||
/// - **Registered, offered** → MRU + `command.run` (e.g. `/model` /
|
||||
/// `/plan` stage the next spawn).
|
||||
pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
|
|
@ -1350,14 +1360,16 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
// path so the text becomes a new session's prompt.
|
||||
return dispatch_dashboard_dispatch(app, text, /* attach */ false);
|
||||
};
|
||||
// Registered but not offered on this surface (session-scoped
|
||||
// hidden from the dropdown, or non-dashboard `dashboard_only`):
|
||||
// error toast — never spawn a session whose first prompt is the
|
||||
// slash text (that was worse than the old loud Action toasts).
|
||||
// Registered but not offered on this surface:
|
||||
// - session-scoped → toast; never spawn with the slash as a prompt
|
||||
// - `visible() == false` (e.g. `/usage` on external auth) → still
|
||||
// `run()` so the command owns the refusal message
|
||||
if !dashboard
|
||||
.dispatch
|
||||
.slash_controller
|
||||
.is_command_offered(command.as_ref(), &app.models)
|
||||
&& command.session_scoped()
|
||||
&& !command.offered_when_session_less()
|
||||
{
|
||||
let name = command.name();
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
|
|
@ -1381,6 +1393,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
bundle_state: &app.bundle_state,
|
||||
screen_mode: app.screen_mode,
|
||||
billing_surface_visible: app.usage_visible,
|
||||
usage_command_visible: !app.has_external_auth_provider,
|
||||
pager_state: crate::settings::PagerLocalSnapshot {
|
||||
multiline_mode: dashboard_multiline,
|
||||
yolo_mode: app.default_yolo,
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
bundle_state: &app.bundle_state,
|
||||
screen_mode: app.screen_mode,
|
||||
billing_surface_visible: app.usage_visible,
|
||||
usage_command_visible: !app.has_external_auth_provider,
|
||||
// PAGER-owned snapshot for slash commands.
|
||||
pager_state: crate::settings::PagerLocalSnapshot {
|
||||
multiline_mode: agent.multiline_mode,
|
||||
|
|
@ -607,15 +608,15 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
});
|
||||
}
|
||||
if let Some(command) = command {
|
||||
if ctx.screen_mode.is_minimal() && !command.available_in_minimal() {
|
||||
// Central minimal gate: commands that drive the deleted
|
||||
// fullscreen pane / dashboard (/find, /dashboard, …)
|
||||
// have nothing to act on in scrollback-native mode.
|
||||
// Surface a friendly system block instead of running them.
|
||||
CommandResult::Message(format!(
|
||||
"/{} is not available in minimal mode",
|
||||
invocation.token
|
||||
))
|
||||
// Central screen-mode gate. Such a command is already
|
||||
// filtered out of every completion surface, but it stays
|
||||
// resolvable so a fully-typed invocation earns a hint that
|
||||
// names the way out instead of leaking to the model.
|
||||
if let Some(refusal) = command
|
||||
.mode_support()
|
||||
.refusal(invocation.token, ctx.screen_mode)
|
||||
{
|
||||
CommandResult::Message(refusal)
|
||||
} else {
|
||||
agent
|
||||
.prompt
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
|||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
!app.has_external_auth_provider,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.active_announcements,
|
||||
|
|
|
|||
|
|
@ -340,6 +340,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
|
|||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
!app.has_external_auth_provider,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.active_announcements,
|
||||
|
|
@ -783,6 +784,7 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
|
|||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
!app.has_external_auth_provider,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.active_announcements,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ fn dispatch_load_session_ungated(
|
|||
agent_mut.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
!app.has_external_auth_provider,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.active_announcements,
|
||||
|
|
@ -862,6 +863,7 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
|
|||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
!app.has_external_auth_provider,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.active_announcements,
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ pub(in crate::app::dispatch) fn dispatch_open_command_palette(app: &mut AppView)
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
agent.prompt.slash_controller.screen_mode(),
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
// Type-to-find: open in input mode (matches Ctrl+P).
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
|
|
@ -257,7 +257,9 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
|
|||
{
|
||||
// Try the chooser; a locked row keeps Browse (`try_enter_picking_enum`
|
||||
// refuses when `row_lock` is set).
|
||||
state.try_enter_picking_enum();
|
||||
if state.try_enter_picking_enum() {
|
||||
state.close_on_picker_exit = true;
|
||||
}
|
||||
}
|
||||
agent.active_modal = Some(ActiveModal::Settings { state });
|
||||
effects
|
||||
|
|
|
|||
|
|
@ -11,37 +11,10 @@ use crate::app::app_view::{ActiveView, AppView};
|
|||
use crate::notifications::{NotificationEvent, NotificationEventKind};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Toggle YOLO mode (auto-approve all permissions).
|
||||
///
|
||||
/// When turning ON: auto-approve all currently queued permissions and
|
||||
/// restore the stashed prompt. Future incoming permissions will be
|
||||
/// auto-approved in `handle_permission_request`.
|
||||
///
|
||||
/// Share the current session via a public URL.
|
||||
///
|
||||
/// Produces Effect::ShareSession which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::ShareSessionComplete shows the URL in scrollback.
|
||||
/// Temporary kill switch: client share links are disabled.
|
||||
pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec<Effect> {
|
||||
if !app.sharing_enabled {
|
||||
app.show_toast("Sharing is disabled");
|
||||
return vec![];
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No active session — error should have been caught by slash command,
|
||||
// but guard here just in case.
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![Effect::ShareSession {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
app.show_toast("Session sharing is temporarily disabled");
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Show session info: fetch via x.ai/session/info and display in scrollback.
|
||||
|
|
|
|||
|
|
@ -1273,13 +1273,9 @@ fn open_url_shows_manual_url_when_browser_unavailable() {
|
|||
"must push a system message with the URL"
|
||||
);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("Could not open a browser"),
|
||||
"fallback copy missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(url),
|
||||
"full billing URL must be visible for copy: {text}"
|
||||
assert_eq!(
|
||||
text,
|
||||
crate::app::link_opener::browser_unavailable_message(url)
|
||||
);
|
||||
let toast = app.agents[&AgentId(0)]
|
||||
.toast
|
||||
|
|
@ -1322,6 +1318,65 @@ fn open_url_does_not_show_fallback_when_opener_succeeds() {
|
|||
let _ = std::fs::remove_file(&url_file);
|
||||
}
|
||||
|
||||
/// Welcome has no scrollback: browser-unavailable OpenUrl must put a
|
||||
/// single-line toast that includes the full URL (no `\n` — the welcome
|
||||
/// painter is one row). Privacy-banner Terms/Policy clicks hit this path.
|
||||
#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)]
|
||||
#[test]
|
||||
fn open_url_welcome_toasts_single_line_url_when_browser_unavailable() {
|
||||
let bad = std::env::temp_dir().join(format!(
|
||||
"grok-open-url-welcome-missing-{}/out.txt",
|
||||
std::process::id()
|
||||
));
|
||||
// SAFETY: serialized via `serial_test` so no other test races the env var.
|
||||
unsafe { std::env::set_var("GROK_TEST_OPEN_URL_FILE", &bad) };
|
||||
|
||||
let mut app = test_app();
|
||||
assert!(
|
||||
matches!(app.active_view, ActiveView::Welcome),
|
||||
"fixture must start on welcome"
|
||||
);
|
||||
|
||||
use crate::app::link_opener::browser_unavailable_line;
|
||||
|
||||
let terms = crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL;
|
||||
let effects = dispatch(Action::OpenUrl(terms.to_string()), &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let toast = app
|
||||
.welcome_toast
|
||||
.as_ref()
|
||||
.map(|(m, _)| m.as_str())
|
||||
.unwrap_or("");
|
||||
// Structure only: clipboard delivery varies by host, so do not lock the
|
||||
// exact "copied" phrase here (constructor unit tests cover both arms).
|
||||
assert!(toast.starts_with(terms), "{toast}");
|
||||
assert!(!toast.contains('\n'), "{toast}");
|
||||
assert!(
|
||||
toast == browser_unavailable_line(terms, true)
|
||||
|| toast == browser_unavailable_line(terms, false),
|
||||
"welcome toast must match a delivery-honest line form: {toast}"
|
||||
);
|
||||
|
||||
// Policy URL is shorter than terms (compile-time constants); second toast
|
||||
// replaces the first in welcome toast state.
|
||||
let policy = crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL;
|
||||
let _ = dispatch(Action::OpenUrl(policy.to_string()), &mut app);
|
||||
let toast = app
|
||||
.welcome_toast
|
||||
.as_ref()
|
||||
.map(|(m, _)| m.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(toast.starts_with(policy), "{toast}");
|
||||
assert!(
|
||||
toast == browser_unavailable_line(policy, true)
|
||||
|| toast == browser_unavailable_line(policy, false),
|
||||
"second welcome toast must match a delivery-honest line form: {toast}"
|
||||
);
|
||||
|
||||
// SAFETY: serialized via `serial_test`; restore the env for other tests.
|
||||
unsafe { std::env::remove_var("GROK_TEST_OPEN_URL_FILE") };
|
||||
}
|
||||
|
||||
/// Credit-limit upsell Q&A submit routes through OpenUrl; when the browser
|
||||
/// is unavailable the full option URL must land in scrollback.
|
||||
#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)]
|
||||
|
|
|
|||
|
|
@ -1688,6 +1688,41 @@ fn dashboard_does_not_advertise_or_dispatch_doctor() {
|
|||
assert_eq!(dashboard.error_toast.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
}
|
||||
/// External-auth hides `/usage` via `visible()`, not session-scope. Typed
|
||||
/// `/usage` on the dashboard must refuse with the command's message, not
|
||||
/// claim it only works in a session.
|
||||
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_slash_usage_hidden_for_external_auth() {
|
||||
let mut app = three_agent_app();
|
||||
app.has_external_auth_provider = true;
|
||||
app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default());
|
||||
open_dashboard(&mut app);
|
||||
let before = app.agents.len();
|
||||
let effects = dispatch_dashboard_dispatch_slash(&mut app, "/usage".into());
|
||||
assert!(effects.is_empty(), "must not enqueue spawn effects");
|
||||
assert_eq!(app.agents.len(), before, "must not add an agent");
|
||||
assert_eq!(app.dashboard.as_ref().unwrap().dispatch.text(), "");
|
||||
let toast = app
|
||||
.dashboard
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.error_toast
|
||||
.as_deref()
|
||||
.expect("error toast for gated /usage");
|
||||
assert!(
|
||||
toast.contains("/usage is not available"),
|
||||
"unexpected toast: {toast}"
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("only works in a session"),
|
||||
"must not mis-label /usage as session-scoped: {toast}"
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("SuperGrok"),
|
||||
"must not upsell billing on external auth: {toast}"
|
||||
);
|
||||
}
|
||||
/// Session-scoped Action builtins must not spawn an agent whose first
|
||||
/// prompt is the slash text (registered + not offered → error toast).
|
||||
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ fn test_app() -> AppView {
|
|||
sharing_enabled: false,
|
||||
plugin_cta_enabled: false,
|
||||
usage_visible: true,
|
||||
has_external_auth_provider: false,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
|
|
|
|||
|
|
@ -3077,7 +3077,7 @@ fn slash_and_exit_input_does_not_trigger_project_picker() {
|
|||
assert!(!input_can_trigger_project_picker(" "));
|
||||
}
|
||||
|
||||
// ── Minimal-mode slash gate tests ───────────────────────────────────
|
||||
// ── Screen-mode slash gate tests ────────────────────────────────────
|
||||
|
||||
/// Returns true if any system block in agent 0's scrollback contains
|
||||
/// `needle`. Avoids `last_system_text`'s "last block must be System" panic
|
||||
|
|
@ -3105,20 +3105,52 @@ fn minimal_mode_blocks_fullscreen_pane_slash_command() {
|
|||
before + 1,
|
||||
"the gate should commit exactly one system block"
|
||||
);
|
||||
let refusal = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
last_system_text(&app, AgentId(0)).contains("not available in minimal mode"),
|
||||
"got: {:?}",
|
||||
last_system_text(&app, AgentId(0))
|
||||
refusal.starts_with("/find isn't available in minimal mode"),
|
||||
"got: {refusal:?}"
|
||||
);
|
||||
assert!(
|
||||
refusal.contains("Run /fullscreen"),
|
||||
"the refusal must name the way out, got: {refusal:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_mode_blocks_minimal_only_slash_command() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.screen_mode = crate::app::ScreenMode::Fullscreen;
|
||||
let effects = dispatch_send_prompt(&mut app, "/expand".to_string());
|
||||
assert!(effects.is_empty(), "got: {effects:?}");
|
||||
let refusal = last_system_text(&app, AgentId(0));
|
||||
assert_eq!(
|
||||
refusal,
|
||||
"/expand isn't available in fullscreen mode — press Tab to focus the scrollback, \
|
||||
then → on the block."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switcher_in_its_own_mode_says_you_are_already_there() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.screen_mode = crate::app::ScreenMode::Minimal;
|
||||
let effects = dispatch_send_prompt(&mut app, "/minimal".to_string());
|
||||
assert!(effects.is_empty(), "got: {effects:?}");
|
||||
assert_eq!(
|
||||
last_system_text(&app, AgentId(0)),
|
||||
"You're already in minimal mode."
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline (`--no-alt-screen`) is a full TUI, so fullscreen-only commands run
|
||||
/// there — the gate keys off "is minimal", not "is `ScreenMode::Fullscreen`".
|
||||
#[test]
|
||||
fn non_minimal_mode_allows_fullscreen_pane_slash_command() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.screen_mode = crate::app::ScreenMode::Inline;
|
||||
let _ = dispatch_send_prompt(&mut app, "/find foo".to_string());
|
||||
assert!(
|
||||
!scrollback_has_system_text(&app, AgentId(0), "not available in minimal mode"),
|
||||
!scrollback_has_system_text(&app, AgentId(0), "isn't available"),
|
||||
"the gate must not fire outside minimal mode"
|
||||
);
|
||||
}
|
||||
|
|
@ -3130,7 +3162,7 @@ fn minimal_mode_allows_mode_agnostic_slash_command() {
|
|||
// `/help` is a minimal-native command (opens the command palette).
|
||||
let _ = dispatch_send_prompt(&mut app, "/help".to_string());
|
||||
assert!(
|
||||
!scrollback_has_system_text(&app, AgentId(0), "not available in minimal mode"),
|
||||
!scrollback_has_system_text(&app, AgentId(0), "isn't available"),
|
||||
"denylist default must keep mode-agnostic commands available"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -665,6 +665,7 @@ fn dispatch_open_settings_opens_then_close_on_reentry() {
|
|||
#[test]
|
||||
fn dispatch_open_settings_focus_reopens_when_already_open() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
let mut app = test_app_with_agent();
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
|
@ -687,6 +688,15 @@ fn dispatch_open_settings_focus_reopens_when_already_open() {
|
|||
Some("coding_data_sharing"),
|
||||
"focused re-entry must land on the requested row"
|
||||
);
|
||||
assert!(
|
||||
matches!(state.mode(), SettingsModalMode::PickingEnum { .. }),
|
||||
"focused re-entry must open the chooser, got {:?}",
|
||||
state.mode()
|
||||
);
|
||||
assert!(
|
||||
state.close_on_picker_exit,
|
||||
"focused re-entry must arm close_on_picker_exit"
|
||||
);
|
||||
}
|
||||
/// Chooser when editable, browse row when locked. The team-admin arm is the
|
||||
/// one a `team_name.is_some()` shortcut would break.
|
||||
|
|
@ -744,6 +754,201 @@ fn dispatch_open_settings_focus_skips_the_chooser_only_when_locked() {
|
|||
"a team admin is not locked"
|
||||
);
|
||||
}
|
||||
/// Focused open that enters the chooser sets `close_on_picker_exit` so Esc
|
||||
/// dismisses the modal (GB-4470). Locked landings stay in Browse with the
|
||||
/// flag clear — chrome Esc already closes.
|
||||
#[test]
|
||||
fn dispatch_open_settings_focus_sets_close_on_picker_exit_when_chooser_opens() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
let mut app = test_app_with_agent();
|
||||
let _ = dispatch(
|
||||
Action::OpenSettingsFocus {
|
||||
key: "coding_data_sharing",
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(
|
||||
matches!(state.mode(), SettingsModalMode::PickingEnum { .. }),
|
||||
"editable focus must open the chooser"
|
||||
);
|
||||
assert!(
|
||||
state.close_on_picker_exit,
|
||||
"deep-link chooser open must set close_on_picker_exit"
|
||||
);
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
let _ = dispatch(
|
||||
Action::OpenSettingsFocus {
|
||||
key: "coding_data_sharing",
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(matches!(state.mode(), SettingsModalMode::Browse));
|
||||
assert!(
|
||||
!state.close_on_picker_exit,
|
||||
"locked focus must not set close_on_picker_exit"
|
||||
);
|
||||
}
|
||||
/// Plain OpenSettings does not arm close-on-picker-Esc.
|
||||
#[test]
|
||||
fn dispatch_open_settings_does_not_set_close_on_picker_exit() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
let mut app = test_app_with_agent();
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(!state.close_on_picker_exit);
|
||||
}
|
||||
/// Full path: `/privacy`-style focus open → Esc dismisses the settings modal.
|
||||
#[test]
|
||||
fn open_settings_focus_esc_closes_settings_modal() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let _ = dispatch(
|
||||
Action::OpenSettingsFocus {
|
||||
key: "coding_data_sharing",
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(matches!(
|
||||
state.mode(),
|
||||
SettingsModalMode::PickingEnum { .. }
|
||||
));
|
||||
assert!(state.close_on_picker_exit);
|
||||
}
|
||||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let _ = app.handle_input(&esc);
|
||||
assert!(
|
||||
app.agents.get(&id).unwrap().active_modal.is_none(),
|
||||
"deep-link Esc must dismiss the settings modal"
|
||||
);
|
||||
}
|
||||
/// Full path: `/privacy`-style focus open → Enter commits and dismisses.
|
||||
#[test]
|
||||
fn open_settings_focus_enter_closes_settings_modal() {
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let _ = dispatch(
|
||||
Action::OpenSettingsFocus {
|
||||
key: "coding_data_sharing",
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(matches!(
|
||||
state.mode(),
|
||||
SettingsModalMode::PickingEnum { .. }
|
||||
));
|
||||
assert!(state.close_on_picker_exit);
|
||||
}
|
||||
let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let outcome = app.handle_input(&enter);
|
||||
assert!(
|
||||
app.agents.get(&id).unwrap().active_modal.is_none(),
|
||||
"deep-link Enter must dismiss the settings modal"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::SetCodingDataSharing { .. })
|
||||
),
|
||||
"deep-link Enter must commit SetCodingDataSharing, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
/// Browse path: OpenSettings → enter picker → Esc keeps modal open in Browse.
|
||||
#[test]
|
||||
fn open_settings_enter_picker_esc_stays_open_in_browse() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &mut agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(state.focus_key("coding_data_sharing"));
|
||||
assert!(state.try_enter_picking_enum());
|
||||
assert!(!state.close_on_picker_exit);
|
||||
}
|
||||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let _ = app.handle_input(&esc);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("browse-path Esc must keep the settings modal open")
|
||||
};
|
||||
assert!(
|
||||
matches!(state.mode(), SettingsModalMode::Browse),
|
||||
"browse-path Esc must return to Browse, got {:?}",
|
||||
state.mode()
|
||||
);
|
||||
}
|
||||
/// `ActionThenClose` closes the modal and forwards the preview-revert Action
|
||||
/// through `apply_settings_outcome` (handle_input path).
|
||||
#[test]
|
||||
fn deep_link_preview_esc_closes_modal_and_forwards_revert_action() {
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::settings_modal::SettingsModalMode;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &mut agent.active_modal else {
|
||||
panic!("settings modal must be open")
|
||||
};
|
||||
assert!(state.focus_key("theme"));
|
||||
assert!(state.try_enter_picking_enum());
|
||||
state.close_on_picker_exit = true;
|
||||
assert!(matches!(
|
||||
state.mode(),
|
||||
SettingsModalMode::PickingEnum { .. }
|
||||
));
|
||||
}
|
||||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let outcome = app.handle_input(&esc);
|
||||
assert!(
|
||||
app.agents.get(&id).unwrap().active_modal.is_none(),
|
||||
"ActionThenClose must clear active_modal"
|
||||
);
|
||||
match outcome {
|
||||
InputOutcome::Action(Action::PreviewTheme(name)) => {
|
||||
assert_eq!(name, "groknight");
|
||||
}
|
||||
other => panic!("expected Action(PreviewTheme), got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// `dispatch_open_reset_confirm` moves the Settings modal state
|
||||
/// into the new `ResetSettingsConfirm` variant, preserving it
|
||||
/// across the confirm dialog's lifecycle. The dispatch arm is
|
||||
|
|
|
|||
|
|
@ -342,12 +342,13 @@ impl SessionFlags {
|
|||
if meta.is_empty() { None } else { Some(meta) }
|
||||
}
|
||||
}
|
||||
/// Workspace-bind `_meta` keys forbidden on chat create/load: backend owns
|
||||
/// workspace for `kind=chat`; the client must not bind Direct/envId/attach.
|
||||
/// Workspace-bind `_meta` keys **always** forbidden on chat create/load.
|
||||
///
|
||||
/// `x.ai/cloud_existing_workspace` is intentionally omitted: scrub keeps it
|
||||
/// iff `x.ai/local_workspace.mode == "attach"`.
|
||||
pub(super) const CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS: &[&str] = &[
|
||||
"envId",
|
||||
"x.ai/cloud_server_id",
|
||||
"x.ai/cloud_existing_workspace",
|
||||
];
|
||||
/// Stamp `_meta["x.ai/session"].kind = "chat"` and strip Build `agentProfile` (K12).
|
||||
pub(super) fn apply_chat_kind_meta(meta: &mut Option<acp::Meta>) {
|
||||
|
|
@ -355,7 +356,23 @@ pub(super) fn apply_chat_kind_meta(meta: &mut Option<acp::Meta>) {
|
|||
obj.insert("x.ai/session".into(), serde_json::json!({ "kind": "chat" }));
|
||||
obj.remove("agentProfile");
|
||||
}
|
||||
/// Shared chat create/load/worktree meta finalize: kind + local stamp + scrub.
|
||||
pub(super) fn finalize_chat_session_meta(
|
||||
meta: &mut Option<acp::Meta>,
|
||||
is_chat_path: bool,
|
||||
#[allow(unused_variables)]
|
||||
session_flags: &SessionFlags,
|
||||
) {
|
||||
if !is_chat_path {
|
||||
return;
|
||||
}
|
||||
apply_chat_kind_meta(meta);
|
||||
scrub_chat_workspace_bind_meta(meta);
|
||||
}
|
||||
/// Remove client workspace-bind keys from chat create/load meta (defense in depth).
|
||||
///
|
||||
/// Narrow scrub exception: keep `x.ai/cloud_existing_workspace` when local
|
||||
/// intent is attach. Never keep `envId` or Direct hub `x.ai/cloud_server_id`.
|
||||
pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option<acp::Meta>) {
|
||||
let Some(obj) = meta.as_mut() else {
|
||||
return;
|
||||
|
|
@ -363,6 +380,9 @@ pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option<acp::Meta>) {
|
|||
for key in CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS {
|
||||
obj.remove(*key);
|
||||
}
|
||||
{
|
||||
obj.remove("x.ai/cloud_existing_workspace");
|
||||
}
|
||||
}
|
||||
/// Metadata returned from effect execution so the event loop can patch
|
||||
/// state that requires a spawned task handle (e.g., auth AbortHandle).
|
||||
|
|
|
|||
|
|
@ -142,9 +142,7 @@ pub(crate) fn execute(
|
|||
#[allow(unused_mut)]
|
||||
let mut meta = session_flags.to_meta();
|
||||
let is_chat_path = chat_kind || session_flags.chat_mode;
|
||||
if is_chat_path {
|
||||
apply_chat_kind_meta(&mut meta);
|
||||
}
|
||||
finalize_chat_session_meta(&mut meta, is_chat_path, session_flags);
|
||||
if let Some(ref mid) = model_id {
|
||||
meta.get_or_insert_with(acp::Meta::new)
|
||||
.insert("modelId".into(), serde_json::json!(mid.0));
|
||||
|
|
@ -238,13 +236,11 @@ pub(crate) fn execute(
|
|||
let tx = acp_tx.clone();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let mut meta = session_flags.to_meta();
|
||||
if chat_kind || session_flags.chat_mode {
|
||||
meta.get_or_insert_with(acp::Meta::new)
|
||||
.insert(
|
||||
"x.ai/session".into(),
|
||||
serde_json::json!({ "kind": "chat" }),
|
||||
finalize_chat_session_meta(
|
||||
&mut meta,
|
||||
chat_kind || session_flags.chat_mode,
|
||||
session_flags,
|
||||
);
|
||||
}
|
||||
if let Some(ref mid) = model_id {
|
||||
meta.get_or_insert_with(acp::Meta::new)
|
||||
.insert("modelId".into(), serde_json::json!(mid.0));
|
||||
|
|
@ -520,10 +516,7 @@ pub(crate) fn execute(
|
|||
let tx = acp_tx.clone();
|
||||
let mut meta = session_flags.to_meta();
|
||||
let is_chat_path = chat_kind || session_flags.chat_mode;
|
||||
if is_chat_path {
|
||||
apply_chat_kind_meta(&mut meta);
|
||||
scrub_chat_workspace_bind_meta(&mut meta);
|
||||
}
|
||||
finalize_chat_session_meta(&mut meta, is_chat_path, session_flags);
|
||||
if let Some(true) = session_flags.restore_code {
|
||||
meta.get_or_insert_with(acp::Meta::new)
|
||||
.insert("x.ai/restore_code".into(), serde_json::Value::Bool(true));
|
||||
|
|
|
|||
|
|
@ -2025,6 +2025,10 @@ fn assert_chat_meta_has_no_workspace_bind_keys(meta: &serde_json::Value) {
|
|||
"chat meta must not include workspace-bind key {key:?}: {meta}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
meta.get("x.ai/cloud_existing_workspace").is_none(),
|
||||
"chat meta without attach must not include existing workspace: {meta}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn chat_create_meta_never_includes_workspace_bind_keys_when_cloud_fields_set() {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,13 +29,19 @@
|
|||
//! seams), where env is set before any process-global's first touch.
|
||||
//!
|
||||
//! Unix-only: the leader transport here is a unix socket.
|
||||
|
||||
use super::actions::{Action, TaskResult};
|
||||
use super::agent::AgentState;
|
||||
use super::agent_view::AgentView;
|
||||
use super::app_view::{AppView, AuthState, TrustState};
|
||||
use super::{acp_handler, dispatch, effects};
|
||||
use crate::acp::leader_bridge::bridge_channels;
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use agent_client_protocol as acp;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use tempfile::TempDir;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -46,19 +52,8 @@ use xai_grok_shell::leader::{
|
|||
LeaderServerControlState, LeaderServerMetadata, ReconnectPolicy, run_leader_server,
|
||||
};
|
||||
use xai_grok_test_support::MockInferenceServer;
|
||||
|
||||
use super::actions::{Action, TaskResult};
|
||||
use super::agent::AgentState;
|
||||
use super::agent_view::AgentView;
|
||||
use super::app_view::{AppView, AuthState, TrustState};
|
||||
use super::{acp_handler, dispatch, effects};
|
||||
use crate::acp::leader_bridge::bridge_channels;
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
const PUMP_TICK: Duration = Duration::from_millis(10);
|
||||
const TURN_BUDGET: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Await a bring-up step with a hard budget so an on-demand run that hangs
|
||||
/// names its phase instead of parking until the test-runner kill.
|
||||
async fn bounded<T>(what: &str, fut: impl std::future::Future<Output = T>) -> T {
|
||||
|
|
@ -66,13 +61,11 @@ async fn bounded<T>(what: &str, fut: impl std::future::Future<Output = T>) -> T
|
|||
.await
|
||||
.unwrap_or_else(|_| panic!("leader-cluster bring-up timed out: {what}"))
|
||||
}
|
||||
|
||||
/// The grok home the agent actually persisted under: `grok_home()` is
|
||||
/// process-cached, so an earlier test in this binary may have pinned it.
|
||||
fn effective_grok_home() -> PathBuf {
|
||||
xai_grok_config::grok_home()
|
||||
}
|
||||
|
||||
/// Concatenated agent-message text across a view's scrollback (copy of the
|
||||
/// acp_handler tests' helper; that one is test-mod private).
|
||||
fn agent_message_text(view: &AgentView) -> String {
|
||||
|
|
@ -86,7 +79,6 @@ fn agent_message_text(view: &AgentView) -> String {
|
|||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One pager client: a full `AppView` behind the production leader bridge.
|
||||
struct ClusterClient {
|
||||
app: AppView,
|
||||
|
|
@ -99,7 +91,6 @@ struct ClusterClient {
|
|||
/// generation bumps after a leader kill/respawn.
|
||||
status_rx: Option<tokio::sync::watch::Receiver<ConnectionStatus>>,
|
||||
}
|
||||
|
||||
impl ClusterClient {
|
||||
/// Drain everything currently ready (inbound ACP + finished tasks).
|
||||
/// Returns whether anything was processed.
|
||||
|
|
@ -119,14 +110,12 @@ impl ClusterClient {
|
|||
}
|
||||
progressed
|
||||
}
|
||||
|
||||
fn drain_pending_effects(&mut self) {
|
||||
if !self.app.pending_effects.is_empty() {
|
||||
let effs = std::mem::take(&mut self.app.pending_effects);
|
||||
self.process_effects(effs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The event loop's `process_effects`, minus terminal/auth-handle wiring
|
||||
/// (that fn is event_loop-private; this mirrors its body).
|
||||
fn process_effects(&mut self, effs: Vec<super::actions::Effect>) {
|
||||
|
|
@ -158,20 +147,17 @@ impl ClusterClient {
|
|||
}
|
||||
self.drain_pending_effects();
|
||||
}
|
||||
|
||||
/// Dispatch a user action and run its effects.
|
||||
fn act(&mut self, action: Action) {
|
||||
let effs = dispatch::dispatch(action, &mut self.app);
|
||||
self.process_effects(effs);
|
||||
}
|
||||
|
||||
/// Pump until `pred(app)` holds, within [`TURN_BUDGET`]. No fixed sleeps
|
||||
/// beyond the pump tick; panics with `what` on expiry. Single-client sugar
|
||||
/// over [`pump_clients_until`] so there is exactly one pump loop.
|
||||
async fn pump_until(&mut self, what: &str, pred: impl Fn(&AppView) -> bool) {
|
||||
pump_clients_until(&mut [self], what, |clients| pred(&clients[0].app)).await;
|
||||
}
|
||||
|
||||
/// The most recently created agent view (scenarios add tabs in order).
|
||||
fn latest_agent(&self) -> &AgentView {
|
||||
self.app
|
||||
|
|
@ -180,7 +166,6 @@ impl ClusterClient {
|
|||
.last()
|
||||
.expect("client has no agent view yet")
|
||||
}
|
||||
|
||||
fn agent_for_session(&self, sid: &str) -> &AgentView {
|
||||
self.app
|
||||
.agents
|
||||
|
|
@ -193,7 +178,6 @@ impl ClusterClient {
|
|||
})
|
||||
.unwrap_or_else(|| panic!("no agent view for session {sid}"))
|
||||
}
|
||||
|
||||
/// Create a new session through the real dispatch → effect → agent path.
|
||||
async fn new_session(&mut self) -> String {
|
||||
self.act(Action::NewSession);
|
||||
|
|
@ -211,7 +195,6 @@ impl ClusterClient {
|
|||
.0
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Attach to an existing session (viewer path) and wait for the replay to
|
||||
/// land.
|
||||
async fn load_session(&mut self, sid: &str) {
|
||||
|
|
@ -228,7 +211,6 @@ impl ClusterClient {
|
|||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Drive one full turn on the active agent and wait until it lands
|
||||
/// (sentinel visible + agent back to Idle).
|
||||
async fn run_turn(&mut self, prompt: &str, sentinel: &str) {
|
||||
|
|
@ -242,12 +224,10 @@ impl ClusterClient {
|
|||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn sever(self) {
|
||||
self.bridge_cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump several clients until `pred` holds across them, within
|
||||
/// [`TURN_BUDGET`]; panics with `what` on expiry.
|
||||
async fn pump_clients_until(
|
||||
|
|
@ -270,7 +250,6 @@ async fn pump_clients_until(
|
|||
tokio::time::sleep(PUMP_TICK).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The cluster: leader server + real agent, plus knobs to kill/respawn the
|
||||
/// leader generation under the same socket path.
|
||||
struct PagerLeaderCluster {
|
||||
|
|
@ -296,18 +275,15 @@ struct PagerLeaderCluster {
|
|||
_env: Vec<crate::test_util::EnvVarGuard>,
|
||||
_grok_home: TempDir,
|
||||
}
|
||||
|
||||
impl PagerLeaderCluster {
|
||||
/// Stand up the cluster. Callers MUST be `#[serial_test::serial(GROK_HOME)]`
|
||||
/// (env mutation) and run inside a current-thread `LocalSet`.
|
||||
async fn start() -> Self {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let server = MockInferenceServer::start().await.expect("mock server");
|
||||
let grok_home = TempDir::new().unwrap();
|
||||
let workdir = TempDir::new().unwrap();
|
||||
let sock_path = grok_home.path().join("leader-cluster.sock");
|
||||
|
||||
let env = vec![
|
||||
crate::test_util::EnvVarGuard::set("GROK_HOME", grok_home.path()),
|
||||
crate::test_util::EnvVarGuard::set("GROK_CLI_CHAT_PROXY_BASE_URL", server.url()),
|
||||
|
|
@ -320,15 +296,12 @@ impl PagerLeaderCluster {
|
|||
// connect_or_spawn) to this cluster's socket.
|
||||
crate::test_util::EnvVarGuard::set(LEADER_SOCKET_ENV, &sock_path),
|
||||
];
|
||||
|
||||
// Hold the flock for the cluster's lifetime (see field doc).
|
||||
let mut flock = LeaderLock::new("");
|
||||
assert!(
|
||||
flock.try_acquire().expect("acquire cluster flock"),
|
||||
"cluster flock unexpectedly held"
|
||||
);
|
||||
flock.write_pid().expect("stamp cluster flock");
|
||||
|
||||
let client_count = Arc::new(AtomicUsize::new(0));
|
||||
let mut cluster = Self {
|
||||
sock_path,
|
||||
|
|
@ -345,7 +318,6 @@ impl PagerLeaderCluster {
|
|||
cluster.spawn_leader_generation().await;
|
||||
cluster
|
||||
}
|
||||
|
||||
/// Bind a fresh leader-server generation at the fixed socket path and
|
||||
/// wire a fresh REAL agent behind it.
|
||||
async fn spawn_leader_generation(&mut self) {
|
||||
|
|
@ -354,15 +326,11 @@ impl PagerLeaderCluster {
|
|||
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let cancel = CancellationToken::new();
|
||||
self.server_cancel = cancel.clone();
|
||||
|
||||
let control_state = LeaderServerControlState::new(LeaderServerMetadata {
|
||||
pid: std::process::id(),
|
||||
socket_path: self.sock_path.clone(),
|
||||
lock_path: self.sock_path.with_extension("lock"),
|
||||
ws_url_suffix: String::new(),
|
||||
// MUST be the client-side comparison source (xai_grok_version), not
|
||||
// this crate's version: a reconnecting client evicts strictly-older
|
||||
// leaders, and "evict" here would signal THIS test process.
|
||||
leader_binary_version: xai_grok_version::VERSION.to_string(),
|
||||
});
|
||||
let sock_for_server = self.sock_path.clone();
|
||||
|
|
@ -387,20 +355,17 @@ impl PagerLeaderCluster {
|
|||
)
|
||||
.await;
|
||||
}));
|
||||
|
||||
generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent(
|
||||
acp_rx,
|
||||
response_tx,
|
||||
));
|
||||
self.generation_tasks = generation_tasks;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
while !self.sock_path.exists() && tokio::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(self.sock_path.exists(), "leader socket never bound");
|
||||
}
|
||||
|
||||
/// Kill the current leader generation (server + agent die together, like
|
||||
/// a real leader process crash) and wait for the socket to vanish.
|
||||
async fn kill_leader(&mut self) {
|
||||
|
|
@ -409,32 +374,19 @@ impl PagerLeaderCluster {
|
|||
while self.sock_path.exists() && tokio::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
// Fail HERE if the old generation never released the socket: its late
|
||||
// shutdown cleanup would otherwise delete the respawned generation's
|
||||
// fresh socket from under it (same-path race), which surfaces as a
|
||||
// confusing reconnect-budget expiry downstream.
|
||||
assert!(
|
||||
!self.sock_path.exists(),
|
||||
"old leader generation never released the socket"
|
||||
);
|
||||
// Abort + drain the generation's agent/bridge tasks (the server task
|
||||
// has already run its socket cleanup above). Channel-closure teardown
|
||||
// is only eventual; without this drain an old agent task could still
|
||||
// be running against the same GROK_HOME when the next generation's
|
||||
// agent starts — two writers on one updates.jsonl, the corruption
|
||||
// class the real leader's flock prevents.
|
||||
for task in self.generation_tasks.drain(..) {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
// The next generation's agent must re-authenticate its ACP surface.
|
||||
self.authenticated = false;
|
||||
}
|
||||
|
||||
async fn respawn_leader(&mut self) {
|
||||
self.spawn_leader_generation().await;
|
||||
}
|
||||
|
||||
/// Connect a pager client. With `reconnect: true` the bridge gets a real
|
||||
/// `LeaderReconnector` (socket pinned via `GROK_LEADER_SOCKET`, flock held
|
||||
/// by the cluster, so reconnects always adopt the in-process server).
|
||||
|
|
@ -454,7 +406,6 @@ impl PagerLeaderCluster {
|
|||
.await
|
||||
.expect("cluster client connect");
|
||||
let (leader_tx, leader_rx) = conn.into_channels();
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let (reconnector, status_rx) = if reconnect {
|
||||
let (status_tx, status_rx) = LeaderReconnector::status_channel();
|
||||
|
|
@ -475,7 +426,6 @@ impl PagerLeaderCluster {
|
|||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let bridge = bridge_channels(
|
||||
leader_tx,
|
||||
leader_rx,
|
||||
|
|
@ -486,8 +436,6 @@ impl PagerLeaderCluster {
|
|||
.expect("bridge spawn");
|
||||
let tx = bridge.channel.tx;
|
||||
let rx = bridge.channel.rx;
|
||||
|
||||
// Same handshake the pager performs after bridging (spawn path).
|
||||
let _init: acp::InitializeResponse = bounded(
|
||||
"initialize",
|
||||
acp_send(
|
||||
|
|
@ -528,14 +476,12 @@ impl PagerLeaderCluster {
|
|||
.expect("authenticate through bridge");
|
||||
self.authenticated = true;
|
||||
}
|
||||
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
app.leader_mode = true;
|
||||
app.auth_state = AuthState::Done;
|
||||
app.trust_state = TrustState::Done;
|
||||
app.project_picker_shown = true;
|
||||
app.cwd = self.workdir.path().to_path_buf();
|
||||
|
||||
let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
ClusterClient {
|
||||
app,
|
||||
|
|
@ -547,7 +493,6 @@ impl PagerLeaderCluster {
|
|||
status_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inference request count (chat/responses/messages only), for
|
||||
/// no-turn-was-re-driven invariants.
|
||||
fn inference_request_count(&self) -> usize {
|
||||
|
|
@ -562,20 +507,15 @@ impl PagerLeaderCluster {
|
|||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PagerLeaderCluster {
|
||||
fn drop(&mut self) {
|
||||
self.server_cancel.cancel();
|
||||
// Best-effort (Drop cannot await): stop the generation's tasks so they
|
||||
// never outlive the env guards / temp dirs dropping right after.
|
||||
for task in self.generation_tasks.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrences(haystack: &str, needle: &str) -> usize {
|
||||
haystack.matches(needle).count()
|
||||
}
|
||||
|
||||
mod scenarios;
|
||||
|
|
|
|||
|
|
@ -2019,6 +2019,12 @@ mod tests {
|
|||
assert!(try_parse_pager(&["grok-pager", "--chat"]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cli_local_workspace_flags_rejected_without_feature() {
|
||||
assert!(try_parse_pager(&["grok-pager", "--local-workspace-attach=srv"]).is_err());
|
||||
assert!(try_parse_pager(&["grok-pager", "--local-workspace"]).is_err());
|
||||
assert!(try_parse_pager(&["grok-pager", "--local-workspace-cwd=/tmp"]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn chat_mode_leader_guard_truth_table() {
|
||||
assert!(session_startup::chat_mode_conflicts_with_leader(true, true));
|
||||
assert!(!session_startup::chat_mode_conflicts_with_leader(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ impl AgentView {
|
|||
cwd,
|
||||
has_session_announcements: slash_controller.has_session_announcements(),
|
||||
billing_surface_visible: slash_controller.billing_surface_visible(),
|
||||
usage_command_visible: slash_controller.usage_command_visible(),
|
||||
workflows_available: slash_controller.workflows_available(),
|
||||
screen_mode: slash_controller.screen_mode(),
|
||||
};
|
||||
|
|
@ -710,7 +711,7 @@ impl AgentView {
|
|||
let filtered = crate::views::modal::filter_palette_entries(
|
||||
state.query(),
|
||||
self.sharing_enabled,
|
||||
self.prompt.slash_controller.screen_mode(),
|
||||
&self.prompt.slash_controller,
|
||||
);
|
||||
let non_sel: Vec<bool> = filtered
|
||||
.iter()
|
||||
|
|
@ -932,7 +933,7 @@ impl AgentView {
|
|||
*entries = crate::views::modal::filter_palette_entries(
|
||||
state.query(),
|
||||
sharing_enabled,
|
||||
self.prompt.slash_controller.screen_mode(),
|
||||
&self.prompt.slash_controller,
|
||||
);
|
||||
state.selected = state.selected.min(entries.len().saturating_sub(1));
|
||||
}
|
||||
|
|
@ -1716,7 +1717,7 @@ impl AgentView {
|
|||
let filtered = modal::filter_palette_entries(
|
||||
state.query(),
|
||||
self.sharing_enabled,
|
||||
self.prompt.slash_controller.screen_mode(),
|
||||
&self.prompt.slash_controller,
|
||||
);
|
||||
let non_sel: Vec<bool> = filtered
|
||||
.iter()
|
||||
|
|
@ -2764,7 +2765,7 @@ mod command_palette_vim_input_tests {
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
agent.prompt.slash_controller.screen_mode(),
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
@ -2796,7 +2797,7 @@ mod command_palette_vim_input_tests {
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
crate::app::ScreenMode::Minimal,
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: {
|
||||
let mut state = PickerState::input_active();
|
||||
|
|
@ -2855,7 +2856,7 @@ mod command_palette_vim_input_tests {
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
crate::app::ScreenMode::Minimal,
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: {
|
||||
let mut state = PickerState::input_active();
|
||||
|
|
|
|||
|
|
@ -1323,7 +1323,7 @@ mod tests {
|
|||
agent.active_modal = Some(ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(
|
||||
agent.sharing_enabled,
|
||||
agent.prompt.slash_controller.screen_mode(),
|
||||
&agent.prompt.slash_controller,
|
||||
),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ pub(crate) fn exec_screen_mode_relaunch(session_id: &str, want_minimal: bool) ->
|
|||
// reader competes with the child for console records and swallows its
|
||||
// first keystrokes.
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
#[allow(clippy::disallowed_methods)] // the parent waits and exits with its status
|
||||
let mut child = cmd.spawn()?;
|
||||
let status = child.wait()?;
|
||||
std::process::exit(status.code().unwrap_or(0));
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ pub fn chat_mode_flag_conflict(
|
|||
}
|
||||
None
|
||||
}
|
||||
pub fn active_local_workspace() -> anyhow::Result<Option<()>> {
|
||||
Ok(None)
|
||||
}
|
||||
/// Conservative shape check for a chat-mode `--resume <id>` passthrough.
|
||||
///
|
||||
/// The id skips disk/GCS resolution and flows to the gateway, but it is also
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ mod tests {
|
|||
output_tokens: output,
|
||||
total_tokens: input + output,
|
||||
cached_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
model_calls: 1,
|
||||
api_duration_ms: 1_000,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
249
crates/codegen/xai-grok-pager/src/headless/cli.rs
Normal file
249
crates/codegen/xai-grok-pager/src/headless/cli.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! Headless CLI parsing: output format, prompt sources, permission rules, and agent args.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use clap::ValueEnum;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
|
||||
pub enum OutputFormat {
|
||||
#[default]
|
||||
Plain,
|
||||
Json,
|
||||
/// NDJSON of the agent native ACP session updates.
|
||||
#[value(name = "streaming-json")]
|
||||
StreamingJson,
|
||||
/// NDJSON in the Anthropic Messages API wire format.
|
||||
#[value(name = "streaming-messages-json")]
|
||||
StreamingMessagesJson,
|
||||
}
|
||||
|
||||
pub fn parse_json_schema(input: &str) -> anyhow::Result<serde_json::Value> {
|
||||
let schema: serde_json::Value = serde_json::from_str(input)
|
||||
.map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?;
|
||||
if !schema.is_object() {
|
||||
anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema");
|
||||
}
|
||||
Ok(schema)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HeadlessPrompt {
|
||||
Text(String),
|
||||
Blocks(Vec<acp::ContentBlock>),
|
||||
}
|
||||
|
||||
impl HeadlessPrompt {
|
||||
/// Build from mutually-exclusive CLI prompt args. `None` = interactive mode.
|
||||
pub fn from_args(
|
||||
single: Option<&str>,
|
||||
prompt_json: Option<&str>,
|
||||
prompt_file: Option<&Path>,
|
||||
) -> anyhow::Result<Option<Self>> {
|
||||
if let Some(text) = single {
|
||||
Self::from_text(text)
|
||||
.map(Some)
|
||||
.map_err(|e| anyhow::anyhow!("--single: {e}"))
|
||||
} else if let Some(json_str) = prompt_json {
|
||||
Self::from_json(json_str)
|
||||
.map(Some)
|
||||
.map_err(|e| anyhow::anyhow!("--prompt-json: {e}"))
|
||||
} else if let Some(path) = prompt_file {
|
||||
Self::from_file(path).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// `.json` files are parsed as content blocks, everything else as text.
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?;
|
||||
|
||||
let context = |e| anyhow::anyhow!("'{}': {e}", path.display());
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||
Self::from_json(&content).map_err(context)
|
||||
} else {
|
||||
Self::from_text(&content).map_err(context)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_text(text: &str) -> anyhow::Result<Self> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("prompt is empty");
|
||||
}
|
||||
Ok(Self::Text(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn from_json(json_str: &str) -> anyhow::Result<Self> {
|
||||
let blocks = parse_prompt_json(json_str)?;
|
||||
Ok(Self::Blocks(blocks))
|
||||
}
|
||||
|
||||
pub fn into_content_blocks(self) -> Vec<acp::ContentBlock> {
|
||||
match self {
|
||||
Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
|
||||
Self::Blocks(blocks) => blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse ACP content blocks from an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`).
|
||||
fn parse_prompt_json(json_str: &str) -> anyhow::Result<Vec<acp::ContentBlock>> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;
|
||||
|
||||
let blocks: Vec<acp::ContentBlock> = match value {
|
||||
serde_json::Value::Array(_) => serde_json::from_value(value)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?,
|
||||
|
||||
serde_json::Value::Object(ref map) => {
|
||||
let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"JSON object must have a \"type\" field \
|
||||
(e.g., {{\"type\": \"acp\", \"content\": [...]}})"
|
||||
)
|
||||
})?;
|
||||
let content = map
|
||||
.get("content")
|
||||
.ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?;
|
||||
|
||||
match format_type {
|
||||
"acp" => serde_json::from_value(content.clone()).map_err(|e| {
|
||||
anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}")
|
||||
})?,
|
||||
other => anyhow::bail!(
|
||||
"Unsupported prompt format type: \"{other}\". Supported types: \"acp\""
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object")
|
||||
}
|
||||
};
|
||||
|
||||
if blocks.is_empty() {
|
||||
anyhow::bail!("content blocks array is empty");
|
||||
}
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list into a vec, or None if empty.
|
||||
pub(crate) fn parse_comma_list(s: Option<&str>) -> Option<Vec<String>> {
|
||||
s.and_then(|s| {
|
||||
let v: Vec<String> = s
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_permission_rules_strict(
|
||||
allow: &[String],
|
||||
deny: &[String],
|
||||
) -> anyhow::Result<Vec<xai_grok_workspace::permission::types::PermissionRule>> {
|
||||
let (rules, errors) = parse_permission_rules_inner(allow, deny);
|
||||
if !errors.is_empty() {
|
||||
let msgs: Vec<String> = errors
|
||||
.into_iter()
|
||||
.map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}"))
|
||||
.collect();
|
||||
anyhow::bail!("{}", msgs.join("; "));
|
||||
}
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
pub fn parse_permission_rules_lenient(
|
||||
allow: &[String],
|
||||
deny: &[String],
|
||||
) -> Vec<xai_grok_workspace::permission::types::PermissionRule> {
|
||||
let (rules, errors) = parse_permission_rules_inner(allow, deny);
|
||||
for (flag, rule, err) in errors {
|
||||
eprintln!("warning: {flag} \"{rule}\": {err}, skipping");
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
// Deny before allow is cosmetic: the policy evaluator is order-independent (deny > ask > allow).
|
||||
pub(crate) fn parse_permission_rules_inner(
|
||||
allow: &[String],
|
||||
deny: &[String],
|
||||
) -> (
|
||||
Vec<xai_grok_workspace::permission::types::PermissionRule>,
|
||||
Vec<(&'static str, String, String)>,
|
||||
) {
|
||||
use xai_grok_workspace::permission::rules::parse_permission_rule;
|
||||
use xai_grok_workspace::permission::types::RuleAction;
|
||||
|
||||
let mut rules = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for rule_str in deny {
|
||||
match parse_permission_rule(rule_str, RuleAction::Deny) {
|
||||
Ok(rule) => rules.push(rule),
|
||||
Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
for rule_str in allow {
|
||||
match parse_permission_rule(rule_str, RuleAction::Allow) {
|
||||
Ok(rule) => rules.push(rule),
|
||||
Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
(rules, errors)
|
||||
}
|
||||
|
||||
pub(crate) enum ResolvedAgent {
|
||||
FilePath(PathBuf),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent {
|
||||
let path = std::path::Path::new(agent);
|
||||
if path.exists() && path.is_file() {
|
||||
ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
|
||||
} else {
|
||||
ResolvedAgent::Name(agent.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cli_agents(
|
||||
json: &str,
|
||||
) -> anyhow::Result<Vec<xai_grok_shell::agent::config::AgentDefinition>> {
|
||||
let map: std::collections::HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?;
|
||||
let mut agents = Vec::with_capacity(map.len());
|
||||
for (name, mut value) in map {
|
||||
if let serde_json::Value::Object(ref mut obj) = value {
|
||||
if !obj.contains_key("promptBody")
|
||||
&& let Some(prompt) = obj.remove("prompt")
|
||||
{
|
||||
obj.insert("promptBody".to_string(), prompt);
|
||||
}
|
||||
obj.entry("name".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String(name.clone()));
|
||||
obj.entry("description".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String(name.clone()));
|
||||
}
|
||||
let mut def = xai_grok_shell::agent::config::AgentDefinition::from_json(&value)
|
||||
.map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?;
|
||||
def.name = name;
|
||||
agents.push(def);
|
||||
}
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_agent_flag(
|
||||
agent: &Option<String>,
|
||||
config: &mut xai_grok_shell::agent::config::Config,
|
||||
) {
|
||||
if let Some(agent) = agent {
|
||||
match resolve_agent_arg(agent) {
|
||||
ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path),
|
||||
ResolvedAgent::Name(name) => config.agent.name = Some(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
308
crates/codegen/xai-grok-pager/src/headless/ext_protocol.rs
Normal file
308
crates/codegen/xai-grok-pager/src/headless/ext_protocol.rs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
//! Decoding of the shell's `x.ai/*` extension notifications into the headless
|
||||
//! [`ExtEvent`] the orchestrator dispatches. Owns the wire envelope shapes and
|
||||
//! the method to event mapping, kept out of `headless.rs`.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use crate::headless::reducer::{Lifecycle, StreamEvent};
|
||||
|
||||
/// Tolerate a numeric `task_id` (version skew) by coercing it to a string, so a
|
||||
/// numeric id does not fail the decode and leak an untracked background task.
|
||||
fn de_task_id<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
match serde_json::Value::deserialize(deserializer)? {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"task_id must be a JSON string or number, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_update_tag(params: &str) -> Option<String> {
|
||||
serde_json::from_str::<serde_json::Value>(params)
|
||||
.ok()?
|
||||
.get("update")?
|
||||
.get("sessionUpdate")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) enum ExtEvent {
|
||||
None,
|
||||
TaskBackgrounded { task_id: String, is_monitor: bool },
|
||||
TaskCompleted { task_id: String },
|
||||
SubagentSpawned { subagent_id: String },
|
||||
SubagentFinished { subagent_id: String },
|
||||
MonitorEvent,
|
||||
Lifecycle(Lifecycle),
|
||||
Stream(Box<StreamEvent>),
|
||||
}
|
||||
|
||||
pub(crate) fn handle_ext_notification(
|
||||
notif: &xai_acp_lib::AcpArgsBox<acp::ExtNotification>,
|
||||
) -> ExtEvent {
|
||||
let method = notif.request.method.as_ref();
|
||||
let params = notif.request.params.get();
|
||||
match method {
|
||||
"x.ai/task_backgrounded" => decode_task_backgrounded(method, params),
|
||||
"x.ai/task_completed" => decode_task_completed(method, params),
|
||||
"x.ai/monitor_event" => ExtEvent::MonitorEvent,
|
||||
"x.ai/session_notification" | "x.ai/session/update" => {
|
||||
decode_session_notification(method, params)
|
||||
}
|
||||
_ => ExtEvent::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_task_backgrounded(method: &str, params: &str) -> ExtEvent {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TaskBgEnvelope {
|
||||
update: TaskBgUpdate,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
||||
enum TaskBgUpdate {
|
||||
TaskBackgrounded {
|
||||
#[serde(deserialize_with = "de_task_id")]
|
||||
task_id: String,
|
||||
#[serde(default)]
|
||||
monitor_description: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
match serde_json::from_str::<TaskBgEnvelope>(params) {
|
||||
Ok(env) => match env.update {
|
||||
TaskBgUpdate::TaskBackgrounded {
|
||||
task_id,
|
||||
monitor_description,
|
||||
} => ExtEvent::TaskBackgrounded {
|
||||
task_id,
|
||||
is_monitor: monitor_description.is_some(),
|
||||
},
|
||||
// Known-tag-on-wrong-carrier: log loudly instead of silently dropping.
|
||||
TaskBgUpdate::Other => {
|
||||
tracing::error!(
|
||||
method,
|
||||
payload = params,
|
||||
"headless: x.ai/task_backgrounded with mismatched sessionUpdate \
|
||||
tag; background task will not be tracked for reaping"
|
||||
);
|
||||
ExtEvent::None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
method,
|
||||
error = %e,
|
||||
payload = params,
|
||||
"headless: undecodable x.ai/task_backgrounded notification; \
|
||||
background task will not be tracked for reaping"
|
||||
);
|
||||
ExtEvent::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_task_completed(method: &str, params: &str) -> ExtEvent {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TaskDoneEnvelope {
|
||||
update: TaskDoneUpdate,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
||||
enum TaskDoneUpdate {
|
||||
TaskCompleted {
|
||||
task_snapshot: TaskSnapshotLite,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TaskSnapshotLite {
|
||||
#[serde(deserialize_with = "de_task_id")]
|
||||
task_id: String,
|
||||
}
|
||||
match serde_json::from_str::<TaskDoneEnvelope>(params) {
|
||||
Ok(env) => match env.update {
|
||||
TaskDoneUpdate::TaskCompleted { task_snapshot } => ExtEvent::TaskCompleted {
|
||||
task_id: task_snapshot.task_id,
|
||||
},
|
||||
// Known-tag-on-wrong-carrier: log loudly instead of silently dropping.
|
||||
TaskDoneUpdate::Other => {
|
||||
tracing::error!(
|
||||
method,
|
||||
payload = params,
|
||||
"headless: x.ai/task_completed with mismatched sessionUpdate \
|
||||
tag; background task completion will not be recorded"
|
||||
);
|
||||
ExtEvent::None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
method,
|
||||
error = %e,
|
||||
payload = params,
|
||||
"headless: undecodable x.ai/task_completed notification; \
|
||||
background task completion will not be recorded"
|
||||
);
|
||||
ExtEvent::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_session_notification(method: &str, params: &str) -> ExtEvent {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
||||
enum XaiUpdate {
|
||||
AutoCompactStarted {
|
||||
percentage: u8,
|
||||
},
|
||||
AutoCompactCompleted {
|
||||
#[serde(default)]
|
||||
tokens_before: Option<u64>,
|
||||
},
|
||||
AutoCompactFailed {
|
||||
error: String,
|
||||
},
|
||||
AutoCompactCancelled {},
|
||||
AutoContinueCompleted {
|
||||
total_tokens: u64,
|
||||
},
|
||||
ImageCompressed {
|
||||
message: String,
|
||||
},
|
||||
SubagentSpawned {
|
||||
subagent_id: String,
|
||||
},
|
||||
SubagentFinished {
|
||||
subagent_id: String,
|
||||
},
|
||||
ResponseStarted {
|
||||
#[serde(default)]
|
||||
message_id: Option<String>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
cache_read_input_tokens: u64,
|
||||
#[serde(default)]
|
||||
cache_creation_input_tokens: u64,
|
||||
},
|
||||
ReasoningCompleted {
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
ResponseCompleted {
|
||||
#[serde(default)]
|
||||
message_id: Option<String>,
|
||||
#[serde(default)]
|
||||
stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<xai_grok_shell::extensions::notification::ResponseUsage>,
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
stop_sequence: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct XaiNotif {
|
||||
update: XaiUpdate,
|
||||
}
|
||||
|
||||
let xai_notif = match serde_json::from_str::<XaiNotif>(params) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
method,
|
||||
error = %e,
|
||||
"headless: malformed x.ai session notification; ignoring"
|
||||
);
|
||||
return ExtEvent::None;
|
||||
}
|
||||
};
|
||||
|
||||
match xai_notif.update {
|
||||
XaiUpdate::AutoCompactStarted { percentage } => {
|
||||
ExtEvent::Lifecycle(Lifecycle::CompactStarted { percentage })
|
||||
}
|
||||
XaiUpdate::AutoCompactCompleted { tokens_before } => {
|
||||
ExtEvent::Lifecycle(Lifecycle::CompactCompleted {
|
||||
pre_tokens: tokens_before.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
XaiUpdate::AutoCompactFailed { error } => {
|
||||
ExtEvent::Lifecycle(Lifecycle::CompactFailed { error })
|
||||
}
|
||||
XaiUpdate::AutoCompactCancelled {} => ExtEvent::Lifecycle(Lifecycle::CompactCancelled),
|
||||
XaiUpdate::AutoContinueCompleted { total_tokens } => {
|
||||
ExtEvent::Lifecycle(Lifecycle::AutoContinue { total_tokens })
|
||||
}
|
||||
XaiUpdate::ImageCompressed { message } => {
|
||||
ExtEvent::Lifecycle(Lifecycle::ImageCompressed { message })
|
||||
}
|
||||
XaiUpdate::SubagentSpawned { subagent_id } => ExtEvent::SubagentSpawned { subagent_id },
|
||||
XaiUpdate::SubagentFinished { subagent_id, .. } => {
|
||||
ExtEvent::SubagentFinished { subagent_id }
|
||||
}
|
||||
XaiUpdate::ResponseStarted {
|
||||
message_id,
|
||||
model,
|
||||
input_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
} => ExtEvent::Stream(Box::new(StreamEvent::ResponseStarted {
|
||||
message_id,
|
||||
model,
|
||||
input_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
})),
|
||||
XaiUpdate::ReasoningCompleted { signature } => {
|
||||
ExtEvent::Stream(Box::new(StreamEvent::ReasoningCompleted { signature }))
|
||||
}
|
||||
XaiUpdate::ResponseCompleted {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence,
|
||||
} => ExtEvent::Stream(Box::new(StreamEvent::ResponseCompleted {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence,
|
||||
})),
|
||||
// Background lifecycle tag on the wrong carrier: log loudly, but a
|
||||
// genuinely unknown display tag stays a clean ignore.
|
||||
XaiUpdate::Other => {
|
||||
if let Some(tag) = session_update_tag(params)
|
||||
&& matches!(tag.as_str(), "task_backgrounded" | "task_completed")
|
||||
{
|
||||
tracing::error!(
|
||||
method,
|
||||
tag,
|
||||
payload = params,
|
||||
"headless: background-task lifecycle tag on a session notification \
|
||||
(expected the dedicated x.ai/task_backgrounded|task_completed method); \
|
||||
background tracking will not be updated"
|
||||
);
|
||||
}
|
||||
ExtEvent::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ext_protocol_tests.rs"]
|
||||
mod tests;
|
||||
405
crates/codegen/xai-grok-pager/src/headless/ext_protocol_tests.rs
Normal file
405
crates/codegen/xai-grok-pager/src/headless/ext_protocol_tests.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
use super::*;
|
||||
use crate::headless::reducer::StreamEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl CapturedLogs {
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for CapturedLogs {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for CapturedLogs {
|
||||
type Writer = CapturedLogs;
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_logs(f: impl FnOnce()) -> String {
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_writer(logs.clone())
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_ansi(false)
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, f);
|
||||
logs.text()
|
||||
}
|
||||
|
||||
fn make_ext_notif(
|
||||
method: &str,
|
||||
update: serde_json::Value,
|
||||
) -> xai_acp_lib::AcpArgsBox<acp::ExtNotification> {
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"update": update,
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
xai_acp_lib::AcpArgs {
|
||||
request: acp::ExtNotification::new(method, raw.into()),
|
||||
response_tx: tx,
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_backgrounded_parses_task_id() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_backgrounded",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_backgrounded",
|
||||
"task_id": "task-abc",
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_backgrounded_numeric_task_id_is_coerced() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_backgrounded",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_backgrounded",
|
||||
"task_id": 4242,
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "4242"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_completed_numeric_task_id_is_coerced() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_completed",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_completed",
|
||||
"task_snapshot": { "task_id": 4242 }
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if),
|
||||
ExtEvent::TaskCompleted { task_id } if task_id == "4242"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_backgrounded_with_monitor_description_is_monitor() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_backgrounded",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_backgrounded",
|
||||
"task_id": "mon-1",
|
||||
"monitor_description": "watching logs",
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_completed_parses_task_id() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_completed",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_completed",
|
||||
"task_snapshot": { "task_id": "task-abc" }
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if),
|
||||
ExtEvent::TaskCompleted { task_id } if task_id == "task-abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_subagent_spawned_and_finished_parse() {
|
||||
let spawned = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sub-1",
|
||||
"parent_session_id": "p",
|
||||
"child_session_id": "c",
|
||||
"subagent_type": "explore",
|
||||
"description": "test"
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(&spawned),
|
||||
ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1"
|
||||
));
|
||||
let finished = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_finished",
|
||||
"subagent_id": "sub-1",
|
||||
"child_session_id": "c",
|
||||
"status": "completed",
|
||||
"tool_calls": 0,
|
||||
"turns": 1,
|
||||
"duration_ms": 5
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(&finished),
|
||||
ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_response_completed_parses_per_response_fields() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "response_completed",
|
||||
"message_id": "msg_01",
|
||||
"stop_reason": "tool_use",
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 4,
|
||||
"cache_read_input_tokens": 2,
|
||||
"cache_creation_input_tokens": 0,
|
||||
},
|
||||
"signature": "sig-xyz",
|
||||
"stop_sequence": "<END>",
|
||||
}),
|
||||
);
|
||||
let ExtEvent::Stream(event) = handle_ext_notification(¬if) else {
|
||||
panic!("expected Stream event");
|
||||
};
|
||||
let StreamEvent::ResponseCompleted {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence,
|
||||
} = *event
|
||||
else {
|
||||
panic!("expected ResponseCompleted");
|
||||
};
|
||||
assert_eq!(message_id.as_deref(), Some("msg_01"));
|
||||
assert_eq!(stop_reason.as_deref(), Some("tool_use"));
|
||||
assert_eq!(signature.as_deref(), Some("sig-xyz"));
|
||||
assert_eq!(stop_sequence.as_deref(), Some("<END>"));
|
||||
let usage = usage.expect("usage present");
|
||||
assert_eq!(usage.input_tokens, 10);
|
||||
assert_eq!(usage.cache_read_input_tokens, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_response_started_parses_per_response_fields() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "response_started",
|
||||
"message_id": "msg_01",
|
||||
"model": "grok-4",
|
||||
"input_tokens": 42,
|
||||
"cache_read_input_tokens": 7,
|
||||
"cache_creation_input_tokens": 3,
|
||||
}),
|
||||
);
|
||||
let ExtEvent::Stream(event) = handle_ext_notification(¬if) else {
|
||||
panic!("expected Stream event");
|
||||
};
|
||||
let StreamEvent::ResponseStarted {
|
||||
message_id,
|
||||
model,
|
||||
input_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
} = *event
|
||||
else {
|
||||
panic!("expected ResponseStarted");
|
||||
};
|
||||
assert_eq!(message_id.as_deref(), Some("msg_01"));
|
||||
assert_eq!(model.as_deref(), Some("grok-4"));
|
||||
assert_eq!(input_tokens, 42);
|
||||
assert_eq!(cache_read_input_tokens, 7);
|
||||
assert_eq!(cache_creation_input_tokens, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_reasoning_completed_parses_signature() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "reasoning_completed",
|
||||
"signature": "sig-xyz",
|
||||
}),
|
||||
);
|
||||
let ExtEvent::Stream(event) = handle_ext_notification(¬if) else {
|
||||
panic!("expected Stream event");
|
||||
};
|
||||
let StreamEvent::ReasoningCompleted { signature } = *event else {
|
||||
panic!("expected ReasoningCompleted");
|
||||
};
|
||||
assert_eq!(signature.as_deref(), Some("sig-xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_undecodable_known_background_task_errors_not_silent() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_backgrounded",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_backgrounded",
|
||||
"task_id": { "nested": "object" },
|
||||
}),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "undecodable known method degrades to None");
|
||||
assert!(
|
||||
logs.contains("task_backgrounded"),
|
||||
"log names the method: {logs}"
|
||||
);
|
||||
assert!(logs.contains("ERROR"), "logged at error level: {logs}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_backgrounded_mismatched_tag_errors_not_silent() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_backgrounded",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_completed",
|
||||
"task_id": "task-abc",
|
||||
}),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "mismatched-tag known method degrades to None");
|
||||
assert!(
|
||||
logs.contains("task_backgrounded"),
|
||||
"log names the method: {logs}"
|
||||
);
|
||||
assert!(logs.contains("ERROR"), "logged at error level: {logs}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_task_completed_mismatched_tag_errors_not_silent() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/task_completed",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "task_backgrounded",
|
||||
"task_snapshot": { "task_id": "task-abc" },
|
||||
}),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "mismatched-tag known method degrades to None");
|
||||
assert!(
|
||||
logs.contains("task_completed"),
|
||||
"log names the method: {logs}"
|
||||
);
|
||||
assert!(logs.contains("ERROR"), "logged at error level: {logs}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_malformed_known_response_boundary_warns_not_silent() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "response_completed",
|
||||
"usage": "not-an-object",
|
||||
}),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "malformed known notification degrades to None");
|
||||
assert!(
|
||||
logs.contains("session notification"),
|
||||
"warning describes the malformed session notification: {logs}"
|
||||
);
|
||||
assert!(logs.contains("WARN"), "logged at warn level: {logs}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_session_update_unknown_method_is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"update": {
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sub-1"
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let notif = xai_acp_lib::AcpArgs {
|
||||
request: acp::ExtNotification::new("x.ai/other", raw.into()),
|
||||
response_tx: tx,
|
||||
}
|
||||
.boxed();
|
||||
assert!(matches!(handle_ext_notification(¬if), ExtEvent::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_session_notification_task_tag_errors_not_silent() {
|
||||
for tag in ["task_backgrounded", "task_completed"] {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": tag,
|
||||
"task_id": "task-abc",
|
||||
}),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "misrouted lifecycle tag degrades to None ({tag})");
|
||||
assert!(logs.contains(tag), "log names the tag {tag}: {logs}");
|
||||
assert!(
|
||||
logs.contains("ERROR"),
|
||||
"logged at error level ({tag}): {logs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_session_notification_unknown_tag_is_clean_ignore() {
|
||||
let notif = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({ "sessionUpdate": "totally_unknown_display_tag" }),
|
||||
);
|
||||
let mut is_none = false;
|
||||
let logs = capture_logs(|| {
|
||||
is_none = matches!(handle_ext_notification(¬if), ExtEvent::None);
|
||||
});
|
||||
assert!(is_none, "unknown display tag degrades to None");
|
||||
assert!(
|
||||
!logs.contains("ERROR"),
|
||||
"an unknown display tag is a clean ignore, not an error: {logs}"
|
||||
);
|
||||
}
|
||||
201
crates/codegen/xai-grok-pager/src/headless/reducer/acp.rs
Normal file
201
crates/codegen/xai-grok-pager/src/headless/reducer/acp.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! The `streaming-json` reducer: native ACP session updates, one JSON object
|
||||
//! per line. Owns its own wire line shapes ([`AcpLine`] et al.).
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::headless::attach_result_usage;
|
||||
use xai_grok_shell::extensions::notification::ResponseUsage;
|
||||
|
||||
use super::{
|
||||
Lifecycle, Reducer, StreamEvent, TurnEnd, attach_structured_output, to_line,
|
||||
tool_call_status_wire,
|
||||
};
|
||||
|
||||
/// `streaming-json` per-response `usage` line (camelCase keys).
|
||||
#[derive(Serialize)]
|
||||
struct AcpUsageLine {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
#[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
|
||||
message_id: Option<String>,
|
||||
#[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
|
||||
stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<ResponseUsage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
/// `streaming-json` line shapes: an xAI `type`-tagged envelope derived from ACP updates.
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AcpLine {
|
||||
Text {
|
||||
data: String,
|
||||
},
|
||||
Thought {
|
||||
data: String,
|
||||
},
|
||||
ToolCall {
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: Option<String>,
|
||||
status: Option<String>,
|
||||
#[serde(rename = "toolName")]
|
||||
tool_name: String,
|
||||
#[serde(rename = "rawInput")]
|
||||
raw_input: Value,
|
||||
content: Value,
|
||||
locations: Value,
|
||||
},
|
||||
ToolCallUpdate {
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
status: Option<String>,
|
||||
content: Value,
|
||||
#[serde(rename = "rawOutput")]
|
||||
raw_output: Value,
|
||||
locations: Value,
|
||||
},
|
||||
Plan {
|
||||
entries: Value,
|
||||
},
|
||||
AvailableCommands {
|
||||
tools: Vec<String>,
|
||||
commands: Vec<String>,
|
||||
},
|
||||
MaxTurnsReached,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
AutoCompactStarted {
|
||||
percentage: u8,
|
||||
},
|
||||
AutoCompactCompleted,
|
||||
AutoCompactFailed {
|
||||
error: String,
|
||||
},
|
||||
AutoCompactCancelled,
|
||||
AutoContinueCompleted {
|
||||
total_tokens: u64,
|
||||
},
|
||||
ImageCompressed {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `streaming-json` terminal `end` line (spend fields merged in by the caller).
|
||||
#[derive(Serialize)]
|
||||
struct AcpEndLine<'a> {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
#[serde(rename = "stopReason")]
|
||||
stop_reason: &'a str,
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: &'a str,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: &'a str,
|
||||
}
|
||||
|
||||
/// `streaming-json`: native ACP session updates, one object per line.
|
||||
pub(crate) struct AcpReducer;
|
||||
|
||||
impl Reducer for AcpReducer {
|
||||
fn reduce(&mut self, event: StreamEvent) -> Vec<Value> {
|
||||
let line = match event {
|
||||
StreamEvent::AgentMessage(data) => AcpLine::Text { data },
|
||||
StreamEvent::AgentThought(data) => AcpLine::Thought { data },
|
||||
StreamEvent::ToolCall(tc) => AcpLine::ToolCall {
|
||||
tool_call_id: tc.tool_call_id,
|
||||
title: tc.title,
|
||||
kind: tc.tool_kind,
|
||||
status: tc.status.and_then(tool_call_status_wire),
|
||||
tool_name: tc.tool_name,
|
||||
raw_input: tc.raw_input,
|
||||
content: tc.content,
|
||||
locations: tc.locations,
|
||||
},
|
||||
StreamEvent::ToolCallUpdate(u) => AcpLine::ToolCallUpdate {
|
||||
tool_call_id: u.tool_call_id,
|
||||
status: u.status.and_then(tool_call_status_wire),
|
||||
content: u.content,
|
||||
raw_output: u.raw_output,
|
||||
locations: u.locations,
|
||||
},
|
||||
StreamEvent::Plan(entries) => AcpLine::Plan { entries },
|
||||
StreamEvent::AvailableCommands {
|
||||
tools,
|
||||
commands,
|
||||
skills: _,
|
||||
} => AcpLine::AvailableCommands { tools, commands },
|
||||
StreamEvent::Lifecycle(l) => return vec![to_line(&acp_lifecycle_line(l))],
|
||||
// These events feed only the Messages reducer's partial framing.
|
||||
StreamEvent::ResponseStarted { .. } | StreamEvent::ReasoningCompleted { .. } => {
|
||||
return vec![];
|
||||
}
|
||||
StreamEvent::ResponseCompleted {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence: _,
|
||||
} => {
|
||||
return vec![to_line(&AcpUsageLine {
|
||||
kind: "usage",
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
})];
|
||||
}
|
||||
};
|
||||
vec![to_line(&line)]
|
||||
}
|
||||
|
||||
fn max_turns(&mut self) -> Vec<Value> {
|
||||
vec![to_line(&AcpLine::MaxTurnsReached)]
|
||||
}
|
||||
|
||||
fn finish(&mut self, end: &TurnEnd<'_>) -> Vec<Value> {
|
||||
let mut line = to_line(&AcpEndLine {
|
||||
kind: "end",
|
||||
stop_reason: end.stop_reason,
|
||||
session_id: end.session_id,
|
||||
request_id: end.request_id,
|
||||
});
|
||||
if let Some(usage) = end.usage {
|
||||
attach_result_usage(&mut line, usage);
|
||||
}
|
||||
attach_structured_output(&mut line, end.structured_output.clone());
|
||||
vec![line]
|
||||
}
|
||||
|
||||
fn error(
|
||||
&mut self,
|
||||
message: &str,
|
||||
usage: Option<&Value>,
|
||||
_duration_ms: u64,
|
||||
_stop_reason: Option<&str>,
|
||||
) -> Vec<Value> {
|
||||
let mut line = to_line(&AcpLine::Error {
|
||||
message: message.to_string(),
|
||||
});
|
||||
if let Some(usage) = usage {
|
||||
attach_result_usage(&mut line, usage);
|
||||
}
|
||||
vec![line]
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_lifecycle_line(l: Lifecycle) -> AcpLine {
|
||||
match l {
|
||||
Lifecycle::CompactStarted { percentage } => AcpLine::AutoCompactStarted { percentage },
|
||||
Lifecycle::CompactCompleted { .. } => AcpLine::AutoCompactCompleted,
|
||||
Lifecycle::CompactFailed { error } => AcpLine::AutoCompactFailed { error },
|
||||
Lifecycle::CompactCancelled => AcpLine::AutoCompactCancelled,
|
||||
Lifecycle::AutoContinue { total_tokens } => AcpLine::AutoContinueCompleted { total_tokens },
|
||||
Lifecycle::ImageCompressed { message } => AcpLine::ImageCompressed { message },
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,817 @@
|
|||
//! The `streaming-messages-json` reducer (Anthropic Messages API wire format).
|
||||
//! The coordinator: owns [`MessagesReducer`] and its [`Reducer`] impl; cohesive
|
||||
//! pieces live in the `wire`/`state`/`partial`/`usage`/`web_search` submodules.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
Lifecycle, Reducer, SessionContext, StreamEvent, ToolCallEvent, ToolCallUpdateEvent, TurnEnd,
|
||||
to_line,
|
||||
};
|
||||
|
||||
mod partial;
|
||||
mod state;
|
||||
mod usage;
|
||||
mod web_search;
|
||||
mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use state::{
|
||||
PartialFraming, PendingResponse, ResponseIdentity, ResponseState, SessionState, TextKind,
|
||||
};
|
||||
use wire::{
|
||||
AssistantFrame, AssistantMessage, CompactBoundaryLine, CompactMetadata, ContentBlock,
|
||||
MessageUsage, MessagesLine, PartialDelta, ResultLine, SystemInitLine, SystemLine,
|
||||
ToolResultBlock, ToolResultLine, ToolResultMessage, messages_permission_mode, new_uuid,
|
||||
};
|
||||
|
||||
/// `streaming-messages-json`: the Messages API wire format.
|
||||
pub(crate) struct MessagesReducer {
|
||||
/// Session facts, populated by `begin`; `None` until then.
|
||||
session: Option<SessionState>,
|
||||
tools: Vec<String>,
|
||||
slash_commands: Vec<String>,
|
||||
/// Skill names for the Messages `init` `skills` field.
|
||||
skills: Vec<String>,
|
||||
init_emitted: bool,
|
||||
max_turns_hit: bool,
|
||||
blocks: Vec<ContentBlock>,
|
||||
open_kind: Option<TextKind>,
|
||||
open_text: String,
|
||||
msg_seq: u64,
|
||||
/// Assistant frames flushed this turn; gates the `result.result` final-text fallback.
|
||||
assistant_frames: u64,
|
||||
/// Completed responses this turn, including contentless ones; the `num_turns` fallback.
|
||||
completed_responses: u64,
|
||||
/// Current response lifecycle phase; dropped at response boundaries so it cannot leak.
|
||||
response: ResponseState,
|
||||
/// In-order signature for the currently-open thinking block, so each block keeps its own.
|
||||
open_signature: Option<String>,
|
||||
/// Terminal tool results buffered for one grouped `user` message, tagged with
|
||||
/// the `tool_use`'s emission order so the group flushes in `tool_use` order.
|
||||
pending_tool_results: Vec<(u64, ToolResultBlock)>,
|
||||
/// Monotonic order stamped on each `tool_use` so a later `tool_result` sorts back into place.
|
||||
next_tool_use_order: u64,
|
||||
/// Unmatched client `tool_use` blocks (id -> emission order); leftovers at turn
|
||||
/// end get an `is_error` `tool_result` to keep the transcript valid.
|
||||
pending_client_tool_uses: std::collections::HashMap<String, u64>,
|
||||
/// In-flight backend `web_search` calls (id -> order + call); query and results
|
||||
/// arrive only at completion, so the `ToolCall` defers here.
|
||||
backend_web_search_calls: std::collections::HashMap<String, (u64, ToolCallEvent)>,
|
||||
/// Count of successful inline backend `web_search` invocations (errored ones excluded, not billed).
|
||||
web_search_requests: u64,
|
||||
/// Text of the most recently flushed assistant frame (the `result.result` value).
|
||||
last_text: String,
|
||||
/// Typed partial-stream framing sub-state; only with `--include-partial-messages`.
|
||||
framing: PartialFraming,
|
||||
/// Monotonic counter for synthesized partial `message_start.id` placeholders.
|
||||
partial_msg_seq: u64,
|
||||
}
|
||||
|
||||
impl MessagesReducer {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
session: None,
|
||||
tools: Vec::new(),
|
||||
slash_commands: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
init_emitted: false,
|
||||
max_turns_hit: false,
|
||||
blocks: Vec::new(),
|
||||
open_kind: None,
|
||||
open_text: String::new(),
|
||||
msg_seq: 0,
|
||||
assistant_frames: 0,
|
||||
completed_responses: 0,
|
||||
response: ResponseState::Idle,
|
||||
open_signature: None,
|
||||
pending_tool_results: Vec::new(),
|
||||
next_tool_use_order: 0,
|
||||
pending_client_tool_uses: std::collections::HashMap::new(),
|
||||
backend_web_search_calls: std::collections::HashMap::new(),
|
||||
web_search_requests: 0,
|
||||
last_text: String::new(),
|
||||
framing: PartialFraming::Idle,
|
||||
partial_msg_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The session id, or `""` before `begin` (the startup-error last resort).
|
||||
fn session_id(&self) -> &str {
|
||||
self.session.as_ref().map_or("", |s| s.session_id.as_str())
|
||||
}
|
||||
|
||||
/// Whether `--include-partial-messages` framing is on; `false` before `begin`.
|
||||
fn include_partials(&self) -> bool {
|
||||
self.session.as_ref().is_some_and(|s| s.include_partials)
|
||||
}
|
||||
|
||||
fn init_line(&self) -> Value {
|
||||
let session = self.session.as_ref();
|
||||
to_line(&MessagesLine::System(SystemLine::Init(SystemInitLine {
|
||||
session_id: self.session_id().to_string(),
|
||||
api_key_source: if session.is_none_or(|s| s.api_key_auth) {
|
||||
"user"
|
||||
} else {
|
||||
"oauth"
|
||||
},
|
||||
model: self.model_or_unknown(),
|
||||
cwd: session.map(|s| s.cwd.clone()).unwrap_or_default(),
|
||||
permission_mode: messages_permission_mode(
|
||||
session.and_then(|s| s.permission_mode.as_deref()),
|
||||
),
|
||||
tools: self.tools.clone(),
|
||||
slash_commands: self.slash_commands.clone(),
|
||||
mcp_servers: session.map(|s| s.mcp_servers.clone()).unwrap_or_default(),
|
||||
skills: self.skills.clone(),
|
||||
uuid: new_uuid(),
|
||||
})))
|
||||
}
|
||||
|
||||
fn ensure_init(&mut self) -> Option<Value> {
|
||||
if self.init_emitted {
|
||||
return None;
|
||||
}
|
||||
self.init_emitted = true;
|
||||
Some(self.init_line())
|
||||
}
|
||||
|
||||
fn append_text(&mut self, kind: TextKind, text: &str) {
|
||||
// Finalize a differing or pending signature-only block so it keeps its position.
|
||||
if self.open_kind.is_some_and(|k| k != kind)
|
||||
|| (self.open_kind.is_none() && self.open_signature.is_some())
|
||||
{
|
||||
self.finalize_open();
|
||||
}
|
||||
self.open_kind = Some(kind);
|
||||
self.open_text.push_str(text);
|
||||
}
|
||||
|
||||
fn finalize_open(&mut self) {
|
||||
// Consume this block's own signature so it stamps onto THIS block, never a later one.
|
||||
let signature = self.open_signature.take();
|
||||
let Some(kind) = self.open_kind.take() else {
|
||||
if let Some(signature) = signature {
|
||||
self.blocks.push(ContentBlock::Thinking {
|
||||
thinking: String::new(),
|
||||
signature,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
let text = std::mem::take(&mut self.open_text);
|
||||
match kind {
|
||||
TextKind::Text => {
|
||||
if !text.is_empty() {
|
||||
self.blocks.push(ContentBlock::Text { text });
|
||||
}
|
||||
}
|
||||
TextKind::Thinking => {
|
||||
if !text.is_empty() || signature.is_some() {
|
||||
self.blocks.push(ContentBlock::Thinking {
|
||||
thinking: text,
|
||||
signature: signature.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_tool_use(&mut self, tc: ToolCallEvent) {
|
||||
self.finalize_open();
|
||||
self.blocks.push(ContentBlock::ToolUse {
|
||||
id: tc.tool_call_id,
|
||||
name: tc.tool_name,
|
||||
input: normalized_tool_input(tc.raw_input),
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a client tool's `tool_use` block to the open frame, with partial framing when enabled.
|
||||
fn emit_client_tool_call(&mut self, out: &mut Vec<Value>, tc: ToolCallEvent) {
|
||||
// Track emission order so out-of-order `tool_result`s sort back into place.
|
||||
let order = self.take_tool_use_order();
|
||||
self.pending_client_tool_uses
|
||||
.insert(tc.tool_call_id.clone(), order);
|
||||
if self.include_partials() {
|
||||
self.partial_signature_only_block(out);
|
||||
self.partial_close_block(out);
|
||||
let id = tc.tool_call_id.clone();
|
||||
let name = tc.tool_name.clone();
|
||||
let input = normalized_tool_input(tc.raw_input.clone());
|
||||
self.add_tool_use(tc);
|
||||
let index = self.blocks.len().saturating_sub(1);
|
||||
self.partial_tool_use(out, index, &id, &name, &input);
|
||||
} else {
|
||||
self.add_tool_use(tc);
|
||||
}
|
||||
}
|
||||
|
||||
// The frame and its partial `message_delta` resolve stop reason, usage, and
|
||||
// stop sequence through these three, so the two renderings never disagree.
|
||||
|
||||
/// Reported reason, else `default`; a `None` default forces null so a failed turn is not mislabeled.
|
||||
fn resolved_stop_reason(&self, default: Option<&str>) -> Option<String> {
|
||||
let default = default?;
|
||||
self.response
|
||||
.pending()
|
||||
.and_then(|p| p.stop_reason.clone())
|
||||
.or_else(|| Some(default.to_string()))
|
||||
}
|
||||
|
||||
/// Reported usage, else the identity's input-side usage (`output_tokens` 0).
|
||||
fn resolved_usage(&self) -> MessageUsage {
|
||||
self.response
|
||||
.pending()
|
||||
.and_then(|p| p.usage.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.response.identity().input_usage())
|
||||
}
|
||||
|
||||
fn resolved_stop_sequence(&self) -> Option<String> {
|
||||
self.response
|
||||
.pending()
|
||||
.and_then(|p| p.stop_sequence.clone())
|
||||
}
|
||||
|
||||
/// Flush the accumulated blocks as one assistant message. `default_stop_reason`
|
||||
/// applies only when no `ResponseCompleted` supplied one; `None` stamps null.
|
||||
fn flush_assistant(&mut self, default_stop_reason: Option<&str>) -> Option<Value> {
|
||||
self.finalize_open();
|
||||
if self.blocks.is_empty() {
|
||||
if self.response.started() {
|
||||
self.completed_responses += 1;
|
||||
}
|
||||
self.clear_pending();
|
||||
return None;
|
||||
}
|
||||
let identity = self.response.identity();
|
||||
let usage = self.resolved_usage();
|
||||
let stop_reason = self.resolved_stop_reason(default_stop_reason);
|
||||
let stop_sequence = self.resolved_stop_sequence();
|
||||
let pending = self.response.take_pending();
|
||||
let mut content = std::mem::take(&mut self.blocks);
|
||||
let fallback_sig = pending
|
||||
.signature
|
||||
.clone()
|
||||
.or_else(|| self.open_signature.take());
|
||||
if let Some(sig) = fallback_sig
|
||||
&& let Some(ContentBlock::Thinking {
|
||||
signature: slot, ..
|
||||
}) = content
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|b| matches!(b, ContentBlock::Thinking { .. }))
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = sig;
|
||||
}
|
||||
self.open_signature = None;
|
||||
let text: String = content
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.last_text = text;
|
||||
let id = pending
|
||||
.message_id
|
||||
.clone()
|
||||
.or_else(|| identity.message_id.clone())
|
||||
.unwrap_or_else(|| {
|
||||
let id = format!("msg_{}", self.msg_seq);
|
||||
self.msg_seq += 1;
|
||||
id
|
||||
});
|
||||
let frame = AssistantFrame {
|
||||
message: AssistantMessage {
|
||||
id,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
model: self.frame_model(&identity),
|
||||
content,
|
||||
stop_reason,
|
||||
stop_sequence,
|
||||
usage,
|
||||
},
|
||||
parent_tool_use_id: None,
|
||||
session_id: self.session_id().to_string(),
|
||||
uuid: new_uuid(),
|
||||
};
|
||||
self.assistant_frames += 1;
|
||||
self.completed_responses += 1;
|
||||
Some(to_line(&MessagesLine::Assistant(frame)))
|
||||
}
|
||||
|
||||
/// Drop all per-response state so none leaks onto a later response.
|
||||
fn clear_pending(&mut self) {
|
||||
self.response.reset();
|
||||
self.open_signature = None;
|
||||
}
|
||||
|
||||
/// Close the open partial message and flush the assistant frame with the same
|
||||
/// default stop reason, so the partial rebuild and frame never disagree.
|
||||
fn close_and_flush(&mut self, out: &mut Vec<Value>, default_stop_reason: Option<&str>) {
|
||||
self.partial_close_message(out, default_stop_reason);
|
||||
if let Some(assistant) = self.flush_assistant(default_stop_reason) {
|
||||
out.push(assistant);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared terminal preamble for `finish`/`error`: init, reconcile deferred web
|
||||
/// searches, close+flush the open frame, then flush grouped tool results.
|
||||
fn flush_terminal_preamble(&mut self, out: &mut Vec<Value>, default_stop_reason: Option<&str>) {
|
||||
if let Some(init) = self.ensure_init() {
|
||||
out.push(init);
|
||||
}
|
||||
self.flush_unresolved_web_searches(out);
|
||||
self.close_and_flush(out, default_stop_reason);
|
||||
self.reconcile_unmatched_client_tools();
|
||||
self.flush_tool_results(out);
|
||||
}
|
||||
|
||||
/// Reconcile deferred `web_search` calls that never terminated: emit each as a
|
||||
/// `server_tool_use` + `web_search_tool_result_error` pair, in invocation order.
|
||||
fn flush_unresolved_web_searches(&mut self, out: &mut Vec<Value>) {
|
||||
if self.backend_web_search_calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut leftovers: Vec<(u64, String)> = self
|
||||
.backend_web_search_calls
|
||||
.drain()
|
||||
.map(|(id, (order, _tc))| (order, id))
|
||||
.collect();
|
||||
leftovers.sort_by_key(|(order, _)| *order);
|
||||
let error = json!({
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": "unavailable",
|
||||
});
|
||||
for (_order, id) in leftovers {
|
||||
// Query never arrived, so empty; the error result reflects an unresolved search.
|
||||
self.append_web_search_result(out, &id, "", &error);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session model for the `init` line and `result` `modelUsage`, or `"unknown"`.
|
||||
fn model_or_unknown(&self) -> String {
|
||||
self.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.model.clone())
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// The model for one response's frames: its own model, then the session model, then `"unknown"`.
|
||||
fn frame_model(&self, identity: &ResponseIdentity) -> String {
|
||||
identity
|
||||
.model
|
||||
.as_deref()
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
self.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.model.clone())
|
||||
.filter(|m| !m.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Flush a completed-but-un-flushed response before new content begins.
|
||||
fn flush_prior_response(&mut self, out: &mut Vec<Value>) {
|
||||
if self.response.is_completed() {
|
||||
self.close_and_flush(out, Some("end_turn"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The next monotonic `tool_use` emission order.
|
||||
fn take_tool_use_order(&mut self) -> u64 {
|
||||
let order = self.next_tool_use_order;
|
||||
self.next_tool_use_order += 1;
|
||||
order
|
||||
}
|
||||
|
||||
/// Buffer one terminal tool result for the next grouped `user` message, tagged
|
||||
/// with its `tool_use`'s emission order.
|
||||
fn buffer_tool_result(&mut self, u: ToolCallUpdateEvent) {
|
||||
let is_error = u.status == Some(acp::ToolCallStatus::Failed);
|
||||
let order = self
|
||||
.pending_client_tool_uses
|
||||
.remove(&u.tool_call_id)
|
||||
.unwrap_or_else(|| self.take_tool_use_order());
|
||||
self.pending_tool_results.push((
|
||||
order,
|
||||
ToolResultBlock {
|
||||
kind: "tool_result",
|
||||
tool_use_id: u.tool_call_id,
|
||||
content: tool_result_content(u.raw_output, u.content),
|
||||
is_error,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Buffer an `is_error` `tool_result` for any client `tool_use` that never got one,
|
||||
/// so every `tool_use` is matched and the transcript stays valid.
|
||||
fn reconcile_unmatched_client_tools(&mut self) {
|
||||
if self.pending_client_tool_uses.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (id, order) in std::mem::take(&mut self.pending_client_tool_uses) {
|
||||
self.pending_tool_results.push((
|
||||
order,
|
||||
ToolResultBlock {
|
||||
kind: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: Value::String("tool call did not complete".to_string()),
|
||||
is_error: true,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the buffered tool results as one grouped `user` message, in `tool_use` order.
|
||||
fn flush_tool_results(&mut self, out: &mut Vec<Value>) {
|
||||
if self.pending_tool_results.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut buffered = std::mem::take(&mut self.pending_tool_results);
|
||||
buffered.sort_by_key(|(order, _)| *order);
|
||||
let content = buffered.into_iter().map(|(_, block)| block).collect();
|
||||
out.push(to_line(&MessagesLine::User(ToolResultLine {
|
||||
message: ToolResultMessage {
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
parent_tool_use_id: None,
|
||||
session_id: self.session_id().to_string(),
|
||||
uuid: new_uuid(),
|
||||
})));
|
||||
}
|
||||
|
||||
/// Flush a prior response's frame and grouped tool results before new content begins.
|
||||
fn flush_boundary(&mut self, out: &mut Vec<Value>) {
|
||||
self.flush_prior_response(out);
|
||||
self.flush_tool_results(out);
|
||||
}
|
||||
|
||||
/// Whether any prior-response state remains that a new `ResponseStarted` must flush first.
|
||||
fn has_unflushed_response(&self) -> bool {
|
||||
self.response.is_active()
|
||||
|| !self.blocks.is_empty()
|
||||
|| !self.open_text.is_empty()
|
||||
|| self.open_signature.is_some()
|
||||
|| !self.pending_tool_results.is_empty()
|
||||
}
|
||||
|
||||
fn result_session_id<'a>(&'a self, end_session_id: &'a str) -> &'a str {
|
||||
if end_session_id.is_empty() {
|
||||
self.session_id()
|
||||
} else {
|
||||
end_session_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Reducer for MessagesReducer {
|
||||
fn begin(&mut self, ctx: SessionContext) -> Vec<Value> {
|
||||
debug_assert!(
|
||||
self.session.is_none(),
|
||||
"MessagesReducer::begin called twice; the session context is set once"
|
||||
);
|
||||
self.session = Some(SessionState {
|
||||
session_id: ctx.session_id,
|
||||
model: ctx.model,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permission_mode,
|
||||
api_key_auth: ctx.api_key_auth,
|
||||
mcp_servers: ctx.mcp_servers,
|
||||
include_partials: ctx.include_partial_messages,
|
||||
context_window: ctx.context_window,
|
||||
});
|
||||
// Init is deferred to the first output line so tool/command lists fill.
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn reduce(&mut self, event: StreamEvent) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
// Metadata accumulates before init; `ResponseCompleted` must not force init.
|
||||
let is_metadata = matches!(
|
||||
event,
|
||||
StreamEvent::AvailableCommands { .. }
|
||||
| StreamEvent::ResponseStarted { .. }
|
||||
| StreamEvent::ReasoningCompleted { .. }
|
||||
| StreamEvent::ResponseCompleted { .. }
|
||||
);
|
||||
if !is_metadata && let Some(init) = self.ensure_init() {
|
||||
out.push(init);
|
||||
}
|
||||
match event {
|
||||
StreamEvent::AvailableCommands {
|
||||
tools,
|
||||
commands,
|
||||
skills,
|
||||
} => {
|
||||
if !tools.is_empty() {
|
||||
self.tools = tools;
|
||||
}
|
||||
// Update commands and skills together so a later empty update clears neither.
|
||||
if !commands.is_empty() {
|
||||
self.slash_commands = commands;
|
||||
self.skills = skills;
|
||||
}
|
||||
}
|
||||
// Skip empty chunks so the partial block index can never desync.
|
||||
StreamEvent::AgentMessage(text) if text.is_empty() => {}
|
||||
StreamEvent::AgentThought(text) if text.is_empty() => {}
|
||||
StreamEvent::AgentMessage(text) => {
|
||||
self.flush_boundary(&mut out);
|
||||
self.partial_signature_only_block(&mut out);
|
||||
if self.include_partials() && self.open_kind.is_some_and(|k| k != TextKind::Text) {
|
||||
self.partial_close_block(&mut out);
|
||||
}
|
||||
self.append_text(TextKind::Text, &text);
|
||||
if self.include_partials() {
|
||||
let index = self.blocks.len();
|
||||
self.partial_delta(
|
||||
&mut out,
|
||||
TextKind::Text,
|
||||
index,
|
||||
PartialDelta::Text { text },
|
||||
);
|
||||
}
|
||||
}
|
||||
StreamEvent::AgentThought(text) => {
|
||||
self.flush_boundary(&mut out);
|
||||
self.partial_signature_only_block(&mut out);
|
||||
if self.include_partials()
|
||||
&& self.open_kind.is_some_and(|k| k != TextKind::Thinking)
|
||||
{
|
||||
self.partial_close_block(&mut out);
|
||||
}
|
||||
self.append_text(TextKind::Thinking, &text);
|
||||
if self.include_partials() {
|
||||
let index = self.blocks.len();
|
||||
self.partial_delta(
|
||||
&mut out,
|
||||
TextKind::Thinking,
|
||||
index,
|
||||
PartialDelta::Thinking { thinking: text },
|
||||
);
|
||||
}
|
||||
}
|
||||
StreamEvent::ToolCall(tc) if tc.backend_web_search => {
|
||||
// Query and results are unknown until completion, so defer; stamp invocation order.
|
||||
let order = self.take_tool_use_order();
|
||||
self.backend_web_search_calls
|
||||
.insert(tc.tool_call_id.clone(), (order, tc));
|
||||
}
|
||||
StreamEvent::ToolCall(tc) => {
|
||||
// Flush a prior tool round's results so rounds interleave on backends without `ResponseStarted`.
|
||||
self.flush_tool_results(&mut out);
|
||||
self.emit_client_tool_call(&mut out, tc);
|
||||
}
|
||||
StreamEvent::ToolCallUpdate(u) => {
|
||||
let terminal = matches!(
|
||||
u.status,
|
||||
Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed)
|
||||
);
|
||||
if terminal {
|
||||
if let Some((_order, tc)) =
|
||||
self.backend_web_search_calls.remove(&u.tool_call_id)
|
||||
{
|
||||
self.finish_web_search(&mut out, tc, u);
|
||||
} else {
|
||||
self.close_and_flush(&mut out, Some("tool_use"));
|
||||
self.buffer_tool_result(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamEvent::Lifecycle(Lifecycle::CompactCompleted { pre_tokens }) => {
|
||||
self.flush_boundary(&mut out);
|
||||
out.push(to_line(&MessagesLine::System(SystemLine::CompactBoundary(
|
||||
CompactBoundaryLine {
|
||||
compact_metadata: CompactMetadata {
|
||||
trigger: "auto",
|
||||
pre_tokens,
|
||||
},
|
||||
session_id: self.session_id().to_string(),
|
||||
uuid: new_uuid(),
|
||||
},
|
||||
))));
|
||||
}
|
||||
StreamEvent::ResponseStarted {
|
||||
message_id,
|
||||
model,
|
||||
input_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
} => {
|
||||
// Flush any prior response before this opens so metadata is not cross-attributed.
|
||||
if self.has_unflushed_response() {
|
||||
if let Some(init) = self.ensure_init() {
|
||||
out.push(init);
|
||||
}
|
||||
self.close_and_flush(&mut out, Some("end_turn"));
|
||||
self.flush_tool_results(&mut out);
|
||||
}
|
||||
// Adopt this response's model as the session model so `init`/`modelUsage` track a switch.
|
||||
if let Some(model) = model.clone()
|
||||
&& !model.is_empty()
|
||||
&& let Some(session) = self.session.as_mut()
|
||||
{
|
||||
session.model = Some(model);
|
||||
}
|
||||
// Clone (never take) the identity so both the partial start and final frame read it.
|
||||
self.response.open(ResponseIdentity {
|
||||
message_id,
|
||||
model,
|
||||
input_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
});
|
||||
}
|
||||
StreamEvent::ReasoningCompleted { signature } => {
|
||||
// A pending signature belongs to a new block, so finalize the current one first.
|
||||
if self.open_signature.is_some() {
|
||||
if self.include_partials() {
|
||||
if let Some(init) = self.ensure_init() {
|
||||
out.push(init);
|
||||
}
|
||||
if self.framing.open_block().is_some() {
|
||||
self.partial_close_block(&mut out);
|
||||
} else {
|
||||
self.partial_signature_only_block(&mut out);
|
||||
}
|
||||
}
|
||||
self.finalize_open();
|
||||
}
|
||||
self.open_signature = signature;
|
||||
}
|
||||
StreamEvent::ResponseCompleted {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence,
|
||||
} => {
|
||||
self.flush_boundary(&mut out);
|
||||
// Drop a late completion for an already-flushed response (id differs), else it cross-attributes.
|
||||
let open_id = self.response.identity().message_id;
|
||||
let stale = self.response.is_started()
|
||||
&& matches!((&open_id, &message_id), (Some(o), Some(d)) if o != d);
|
||||
if stale {
|
||||
tracing::warn!(
|
||||
open_id = ?open_id,
|
||||
completed_id = ?message_id,
|
||||
"messages: dropping late ResponseCompleted for an already-flushed response"
|
||||
);
|
||||
} else {
|
||||
let usage: Option<MessageUsage> = usage.as_ref().map(MessageUsage::from);
|
||||
self.response.complete(PendingResponse {
|
||||
message_id,
|
||||
stop_reason,
|
||||
usage,
|
||||
signature,
|
||||
stop_sequence,
|
||||
});
|
||||
}
|
||||
}
|
||||
StreamEvent::Lifecycle(_) | StreamEvent::Plan(_) => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn max_turns(&mut self) -> Vec<Value> {
|
||||
self.max_turns_hit = true;
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn finish(&mut self, end: &TurnEnd<'_>) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let refused = end.stop_reason == "refusal";
|
||||
let cancelled = end.stop_reason == "cancelled";
|
||||
let structured_err = match &end.structured_output {
|
||||
Some(Err(e)) => Some(e.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// Default stop reason when no `ResponseCompleted` supplied one; `null` when the turn did not complete normally.
|
||||
let did_not_complete_normally =
|
||||
self.max_turns_hit || refused || cancelled || structured_err.is_some();
|
||||
let flush_default = if did_not_complete_normally {
|
||||
None
|
||||
} else {
|
||||
match end.stop_reason {
|
||||
"max_tokens" => Some("max_tokens"),
|
||||
_ => Some("end_turn"),
|
||||
}
|
||||
};
|
||||
self.flush_terminal_preamble(&mut out, flush_default);
|
||||
let (subtype, is_error, errors) = if self.max_turns_hit {
|
||||
(
|
||||
"error_max_turns",
|
||||
true,
|
||||
Some(vec!["Reached the maximum number of turns".to_string()]),
|
||||
)
|
||||
} else if refused {
|
||||
(
|
||||
"error_during_execution",
|
||||
true,
|
||||
Some(vec!["The model refused to continue".to_string()]),
|
||||
)
|
||||
} else if cancelled {
|
||||
// No `cancelled` subtype in the Messages SDK, so use the catch-all `error_during_execution`.
|
||||
(
|
||||
"error_during_execution",
|
||||
true,
|
||||
Some(vec!["cancelled".to_string()]),
|
||||
)
|
||||
} else if let Some(msg) = structured_err {
|
||||
("error_max_structured_output_retries", true, Some(vec![msg]))
|
||||
} else {
|
||||
("success", false, None)
|
||||
};
|
||||
let structured_output = match end.structured_output.clone() {
|
||||
Some(Ok(value)) if !is_error => Some(value),
|
||||
_ => None,
|
||||
};
|
||||
let ru = self.messages_result_usage(end.usage);
|
||||
out.push(to_line(&MessagesLine::Result(Box::new(ResultLine {
|
||||
subtype,
|
||||
is_error,
|
||||
duration_ms: end.duration_ms,
|
||||
duration_api_ms: ru.duration_api_ms,
|
||||
num_turns: ru.num_turns,
|
||||
// Fall back to the caller's buffer only when no frame was flushed; else `last_text` is authoritative.
|
||||
result: (!is_error).then(|| {
|
||||
if self.assistant_frames == 0 && self.last_text.is_empty() {
|
||||
end.result_text.to_string()
|
||||
} else {
|
||||
self.last_text.clone()
|
||||
}
|
||||
}),
|
||||
stop_reason: Some(end.stop_reason.to_string()),
|
||||
total_cost_usd: ru.total_cost_usd,
|
||||
usage: ru.usage,
|
||||
model_usage: ru.model_usage,
|
||||
structured_output,
|
||||
errors,
|
||||
session_id: self.result_session_id(end.session_id).to_string(),
|
||||
uuid: new_uuid(),
|
||||
}))));
|
||||
out
|
||||
}
|
||||
|
||||
fn error(
|
||||
&mut self,
|
||||
message: &str,
|
||||
usage: Option<&Value>,
|
||||
duration_ms: u64,
|
||||
stop_reason: Option<&str>,
|
||||
) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
// Max-tokens truncation stamps `max_tokens`; any other error falls back to `null`.
|
||||
let flush_default = match stop_reason {
|
||||
Some("max_tokens") => Some("max_tokens"),
|
||||
_ => None,
|
||||
};
|
||||
self.flush_terminal_preamble(&mut out, flush_default);
|
||||
let ru = self.messages_result_usage(usage);
|
||||
out.push(to_line(&MessagesLine::Result(Box::new(ResultLine {
|
||||
subtype: "error_during_execution",
|
||||
is_error: true,
|
||||
duration_ms,
|
||||
duration_api_ms: ru.duration_api_ms,
|
||||
num_turns: ru.num_turns,
|
||||
result: None,
|
||||
stop_reason: stop_reason.map(str::to_string),
|
||||
total_cost_usd: ru.total_cost_usd,
|
||||
usage: ru.usage,
|
||||
model_usage: ru.model_usage,
|
||||
structured_output: None,
|
||||
errors: Some(vec![message.to_string()]),
|
||||
session_id: self.session_id().to_string(),
|
||||
uuid: new_uuid(),
|
||||
}))));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tool_use.input` must be a JSON object; anything else degrades to `{}`.
|
||||
fn normalized_tool_input(raw: Value) -> Value {
|
||||
if raw.is_object() { raw } else { json!({}) }
|
||||
}
|
||||
|
||||
/// Reduce a tool result to a `tool_result.content` string (verbatim, else compact JSON).
|
||||
fn tool_result_content(output: Value, content: Value) -> Value {
|
||||
match output {
|
||||
Value::String(s) => Value::String(s),
|
||||
Value::Null => match &content {
|
||||
Value::Array(items) if !items.is_empty() => Value::String(content.to_string()),
|
||||
_ => Value::String(String::new()),
|
||||
},
|
||||
other => Value::String(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
//! The `--include-partial-messages` stream framing for `streaming-messages-json`:
|
||||
//! the raw Messages API `stream_event` mechanics and their interaction with the
|
||||
//! typed [`PartialFraming`] state. Only reachable when partial messages are on.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::headless::reducer::to_line;
|
||||
|
||||
use super::MessagesReducer;
|
||||
use super::state::{OpenBlock, PartialFraming, TextKind};
|
||||
use super::wire::{
|
||||
EmptyObject, MessageDeltaBody, MessagesLine, PartialBlock, PartialDelta, PartialEventLine,
|
||||
PartialMessage, StreamEventBody, new_uuid,
|
||||
};
|
||||
|
||||
impl MessagesReducer {
|
||||
/// Wrap a raw Messages API stream event in a `stream_event` line.
|
||||
fn partial_wrap(&self, event: StreamEventBody) -> Value {
|
||||
to_line(&MessagesLine::StreamEvent(PartialEventLine {
|
||||
event: to_line(&event),
|
||||
parent_tool_use_id: None,
|
||||
session_id: self.session_id().to_string(),
|
||||
uuid: new_uuid(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Open the partial `message_start` on first use, carrying the real id, model,
|
||||
/// and input-side usage (or a synthesized id and zero usage when absent).
|
||||
fn partial_open_message(&mut self, out: &mut Vec<Value>) {
|
||||
if self.framing.message_open() {
|
||||
return;
|
||||
}
|
||||
// Clone (never move out) so the real values remain for the final frame.
|
||||
let identity = self.response.identity();
|
||||
let id = identity.message_id.clone().unwrap_or_else(|| {
|
||||
let id = format!("msg_{}", self.partial_msg_seq);
|
||||
self.partial_msg_seq += 1;
|
||||
id
|
||||
});
|
||||
let model = self.frame_model(&identity);
|
||||
out.push(self.partial_wrap(StreamEventBody::MessageStart {
|
||||
message: PartialMessage {
|
||||
id,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: Vec::new(),
|
||||
stop_reason: None,
|
||||
stop_sequence: None,
|
||||
usage: identity.input_usage(),
|
||||
},
|
||||
}));
|
||||
self.framing = PartialFraming::MessageOpen { block: None };
|
||||
}
|
||||
|
||||
/// Emit the partial framing for a text/thinking delta at `index`, opening the
|
||||
/// message and content block on first use.
|
||||
pub(super) fn partial_delta(
|
||||
&mut self,
|
||||
out: &mut Vec<Value>,
|
||||
kind: TextKind,
|
||||
index: usize,
|
||||
delta: PartialDelta,
|
||||
) {
|
||||
self.partial_open_message(out);
|
||||
let open = self.framing.open_block().unwrap_or_else(|| {
|
||||
let content_block = match kind {
|
||||
TextKind::Text => PartialBlock::Text { text: "" },
|
||||
TextKind::Thinking => PartialBlock::Thinking {
|
||||
thinking: "",
|
||||
signature: "",
|
||||
},
|
||||
};
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStart {
|
||||
index,
|
||||
content_block,
|
||||
}));
|
||||
let block = OpenBlock { index, kind };
|
||||
self.framing = PartialFraming::MessageOpen { block: Some(block) };
|
||||
block
|
||||
});
|
||||
// Target the open block's own index, not the caller's, so a delta cannot drift.
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta {
|
||||
index: open.index,
|
||||
delta,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Emit a full `tool_use` content block in the partial stream (one `input_json_delta`).
|
||||
pub(super) fn partial_tool_use(
|
||||
&mut self,
|
||||
out: &mut Vec<Value>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
name: &str,
|
||||
input: &Value,
|
||||
) {
|
||||
self.partial_open_message(out);
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStart {
|
||||
index,
|
||||
content_block: PartialBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
input: EmptyObject {},
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta {
|
||||
index,
|
||||
delta: PartialDelta::InputJson {
|
||||
partial_json: input.to_string(),
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index }));
|
||||
}
|
||||
|
||||
/// Emit the partial framing for a `server_tool_use` block (start, `input_json_delta`, stop).
|
||||
pub(super) fn partial_server_tool_use(
|
||||
&mut self,
|
||||
out: &mut Vec<Value>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
query: &str,
|
||||
) {
|
||||
self.partial_open_message(out);
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStart {
|
||||
index,
|
||||
content_block: PartialBlock::ServerToolUse {
|
||||
id: id.to_string(),
|
||||
name: "web_search",
|
||||
input: EmptyObject {},
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta {
|
||||
index,
|
||||
delta: PartialDelta::InputJson {
|
||||
partial_json: json!({ "query": query }).to_string(),
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index }));
|
||||
}
|
||||
|
||||
/// Emit the partial framing for a `web_search_tool_result` block (hits ride `content_block_start`).
|
||||
pub(super) fn partial_web_search_result(
|
||||
&mut self,
|
||||
out: &mut Vec<Value>,
|
||||
index: usize,
|
||||
tool_use_id: &str,
|
||||
hits: &Value,
|
||||
) {
|
||||
self.partial_open_message(out);
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStart {
|
||||
index,
|
||||
content_block: PartialBlock::WebSearchToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: hits.clone(),
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index }));
|
||||
}
|
||||
|
||||
/// Emit the partial framing for a signature-only thinking block (start + `signature_delta` + stop).
|
||||
/// The signature is cloned (not taken) so `finalize_open` materializes the same block once.
|
||||
pub(super) fn partial_signature_only_block(&mut self, out: &mut Vec<Value>) {
|
||||
if !self.include_partials()
|
||||
|| self.open_kind.is_some()
|
||||
|| self.framing.open_block().is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(signature) = self.open_signature.clone() else {
|
||||
return;
|
||||
};
|
||||
self.partial_open_message(out);
|
||||
let index = self.blocks.len();
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStart {
|
||||
index,
|
||||
content_block: PartialBlock::Thinking {
|
||||
thinking: "",
|
||||
signature: "",
|
||||
},
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta {
|
||||
index,
|
||||
delta: PartialDelta::Signature { signature },
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index }));
|
||||
}
|
||||
|
||||
/// Close the open content block. A thinking block emits `signature_delta` first
|
||||
/// when its signature is known; cloned (not taken) so `finalize_open` can reuse it.
|
||||
pub(super) fn partial_close_block(&mut self, out: &mut Vec<Value>) {
|
||||
let Some(block) = self.framing.open_block() else {
|
||||
return;
|
||||
};
|
||||
let index = block.index;
|
||||
if block.kind == TextKind::Thinking {
|
||||
let sig = self
|
||||
.open_signature
|
||||
.clone()
|
||||
.or_else(|| self.response.pending().and_then(|p| p.signature.clone()));
|
||||
if let Some(sig) = sig {
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta {
|
||||
index,
|
||||
delta: PartialDelta::Signature { signature: sig },
|
||||
}));
|
||||
}
|
||||
}
|
||||
out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index }));
|
||||
self.framing = PartialFraming::MessageOpen { block: None };
|
||||
}
|
||||
|
||||
/// Close the open message framing before a frame is flushed. `default_stop_reason`
|
||||
/// must match `flush_assistant`'s so the partial rebuild and frame never disagree.
|
||||
pub(super) fn partial_close_message(
|
||||
&mut self,
|
||||
out: &mut Vec<Value>,
|
||||
default_stop_reason: Option<&str>,
|
||||
) {
|
||||
if !self.include_partials() {
|
||||
return;
|
||||
}
|
||||
self.partial_close_block(out);
|
||||
self.partial_signature_only_block(out);
|
||||
if !self.framing.message_open() && self.response.started() {
|
||||
self.partial_open_message(out);
|
||||
}
|
||||
if self.framing.message_open() {
|
||||
let stop_reason = self.resolved_stop_reason(default_stop_reason);
|
||||
let usage = self.resolved_usage();
|
||||
let stop_sequence = self.resolved_stop_sequence();
|
||||
out.push(self.partial_wrap(StreamEventBody::MessageDelta {
|
||||
delta: MessageDeltaBody {
|
||||
stop_reason,
|
||||
stop_sequence,
|
||||
},
|
||||
usage,
|
||||
}));
|
||||
out.push(self.partial_wrap(StreamEventBody::MessageStop));
|
||||
self.framing = PartialFraming::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
//! The `streaming-messages-json` reducer state: the per-response phase state
|
||||
//! machine, partial-framing state, terminal metadata buffer, and session facts.
|
||||
|
||||
use crate::headless::reducer::McpServer;
|
||||
|
||||
use super::wire::MessageUsage;
|
||||
|
||||
/// Metadata from the latest `ResponseCompleted`, cleared by the next `flush_assistant`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PendingResponse {
|
||||
pub(super) message_id: Option<String>,
|
||||
pub(super) stop_reason: Option<String>,
|
||||
pub(super) usage: Option<MessageUsage>,
|
||||
pub(super) signature: Option<String>,
|
||||
/// Provider's matched stop sequence; set only when `stop_reason == "stop_sequence"`.
|
||||
pub(super) stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
/// The real per-response identity from `ResponseStarted`: `message.id`, `model`,
|
||||
/// and input-side usage. Retained (cloned, never moved) so both the partial
|
||||
/// `message_start` and the final frame recover the same id/model/usage.
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct ResponseIdentity {
|
||||
pub(super) message_id: Option<String>,
|
||||
pub(super) model: Option<String>,
|
||||
pub(super) input_tokens: u64,
|
||||
pub(super) cache_read_input_tokens: u64,
|
||||
pub(super) cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
impl ResponseIdentity {
|
||||
/// The input-side `message.usage` this identity seeds (`output_tokens` stays 0).
|
||||
pub(super) fn input_usage(&self) -> MessageUsage {
|
||||
MessageUsage {
|
||||
input_tokens: self.input_tokens,
|
||||
cache_read_input_tokens: self.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: self.cache_creation_input_tokens,
|
||||
..MessageUsage::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open block within a partial `message_start` envelope: its wire `index` and kind.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct OpenBlock {
|
||||
pub(super) index: usize,
|
||||
pub(super) kind: TextKind,
|
||||
}
|
||||
|
||||
/// Typed `--include-partial-messages` framing state; an enum makes "block open with no message" unrepresentable.
|
||||
pub(super) enum PartialFraming {
|
||||
/// No partial `message_start` envelope is open.
|
||||
Idle,
|
||||
/// A `message_start` envelope is open; `block` is the open content block, if any.
|
||||
MessageOpen { block: Option<OpenBlock> },
|
||||
}
|
||||
|
||||
impl PartialFraming {
|
||||
pub(super) fn message_open(&self) -> bool {
|
||||
matches!(self, PartialFraming::MessageOpen { .. })
|
||||
}
|
||||
|
||||
pub(super) fn open_block(&self) -> Option<OpenBlock> {
|
||||
match self {
|
||||
PartialFraming::MessageOpen { block } => *block,
|
||||
PartialFraming::Idle => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle phase of the current model response. The retained identity and
|
||||
/// pending metadata are dropped only when the response flushes, so one response's
|
||||
/// id, usage, and signature cannot leak onto the next.
|
||||
#[derive(Default)]
|
||||
pub(super) enum ResponseState {
|
||||
/// No response is open.
|
||||
#[default]
|
||||
Idle,
|
||||
/// A `ResponseStarted` opened this response; its identity is retained until flush.
|
||||
Started(ResponseIdentity),
|
||||
/// A `ResponseCompleted` closed this response; awaiting flush. Retains the
|
||||
/// identity and whether a `ResponseStarted` opened it.
|
||||
Completed {
|
||||
identity: ResponseIdentity,
|
||||
pending: PendingResponse,
|
||||
started: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResponseState {
|
||||
/// This response's retained identity (default when none was surfaced).
|
||||
pub(super) fn identity(&self) -> ResponseIdentity {
|
||||
match self {
|
||||
ResponseState::Idle => ResponseIdentity::default(),
|
||||
ResponseState::Started(identity) | ResponseState::Completed { identity, .. } => {
|
||||
identity.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `ResponseStarted` opened the current response.
|
||||
pub(super) fn started(&self) -> bool {
|
||||
match self {
|
||||
ResponseState::Idle => false,
|
||||
ResponseState::Started(_) => true,
|
||||
ResponseState::Completed { started, .. } => *started,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `ResponseCompleted` has closed but not yet flushed the response.
|
||||
pub(super) fn is_completed(&self) -> bool {
|
||||
matches!(self, ResponseState::Completed { .. })
|
||||
}
|
||||
|
||||
/// Whether a `ResponseStarted` opened this response and it hasn't completed.
|
||||
pub(super) fn is_started(&self) -> bool {
|
||||
matches!(self, ResponseState::Started(_))
|
||||
}
|
||||
|
||||
/// Whether a response is open at all (not `Idle`).
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
!matches!(self, ResponseState::Idle)
|
||||
}
|
||||
|
||||
/// The terminal metadata awaiting flush, if the response completed.
|
||||
pub(super) fn pending(&self) -> Option<&PendingResponse> {
|
||||
match self {
|
||||
ResponseState::Completed { pending, .. } => Some(pending),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the response with a real identity from `ResponseStarted`. A well-formed
|
||||
/// stream always transitions from `Idle`; the debug assertion catches a skipped flush.
|
||||
pub(super) fn open(&mut self, identity: ResponseIdentity) {
|
||||
debug_assert!(
|
||||
matches!(self, ResponseState::Idle),
|
||||
"ResponseState::open called on a non-Idle response; the coordinator \
|
||||
must flush the prior response first"
|
||||
);
|
||||
*self = ResponseState::Started(identity);
|
||||
}
|
||||
|
||||
/// Record the terminal `ResponseCompleted`, retaining the identity and `started` marker.
|
||||
pub(super) fn complete(&mut self, pending: PendingResponse) {
|
||||
*self = ResponseState::Completed {
|
||||
identity: self.identity(),
|
||||
started: self.started(),
|
||||
pending,
|
||||
};
|
||||
}
|
||||
|
||||
/// Take the terminal metadata and reset to `Idle`, dropping the retained identity.
|
||||
pub(super) fn take_pending(&mut self) -> PendingResponse {
|
||||
match std::mem::take(self) {
|
||||
ResponseState::Completed { pending, .. } => pending,
|
||||
_ => PendingResponse::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop all per-response state so nothing leaks onto the next response.
|
||||
pub(super) fn reset(&mut self) {
|
||||
*self = ResponseState::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// The session facts captured at `MessagesReducer::begin`; `model` is `Option`
|
||||
/// because a backend may not surface it until the first `ResponseStarted`.
|
||||
pub(super) struct SessionState {
|
||||
pub(super) session_id: String,
|
||||
pub(super) model: Option<String>,
|
||||
pub(super) cwd: String,
|
||||
pub(super) permission_mode: Option<String>,
|
||||
/// True when the session authenticated with an API key (vs OAuth).
|
||||
pub(super) api_key_auth: bool,
|
||||
pub(super) mcp_servers: Vec<McpServer>,
|
||||
pub(super) include_partials: bool,
|
||||
/// The current model's total context window in tokens, when known.
|
||||
pub(super) context_window: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum TextKind {
|
||||
Text,
|
||||
Thinking,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue