F3: Kimi inference pipeline + full grok cloud-surface excision

Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
  dialect (thinking-field mapping, model_id stripping, empty-content
  tool-call message fix, stream_options.include_usage), with kimi-cli
  source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
  refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
  x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)

Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
  share command, credit bar: deleted (single local session lane;
  paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
  gate/tier/team/ZDR fields, app/subscription.rs watch loop,
  dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
  chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
  SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
  RPC; Kigi is zero-egress — nothing to share or retain remotely)

Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
  (https://www.kimi.com/code/authorize_device?user_code=..., verified
  against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
  URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
  wiremock fixture URLs into the real browser (root cause of the
  "garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
  kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
This commit is contained in:
2026-07-17 16:05:51 -04:00
parent fe1f885bb3
commit ea0ce9d15f
231 changed files with 4730 additions and 26358 deletions
@@ -274,7 +274,7 @@ pub(super) fn handle_auth_complete(
app.auth_state = AuthState::Done;
app.auth_show_raw_url = false;
app.welcome_prompt_focused = !app.is_access_blocked();
app.welcome_prompt_focused = true;
app.auth_code_input.clear();
// Mid-session re-auth (`/login` or a 401 prompt): restore the
@@ -312,9 +312,6 @@ pub(super) fn handle_auth_complete(
}
}
let mut effects = dispatch(Action::RequestBundleStatus, app);
if app.usage_visible {
effects.push(Effect::FetchAppBilling);
}
effects.extend(retry_effects);
return effects;
}
@@ -322,27 +319,9 @@ pub(super) fn handle_auth_complete(
// status only; shell auto-syncs post-auth
let mut effects = dispatch(Action::RequestBundleStatus, app);
// Start auto-checking subscription if gated.
// Check immediately (don't wait 5s) then schedule the timer.
if !app.has_access() {
app.paywall_check_started = Some(std::time::Instant::now());
effects.push(Effect::CheckSubscription { verify: None });
effects.push(Effect::SchedulePaywallCheck);
}
// Fetch billing so the welcome screen can show a credit warning.
if app.usage_visible {
effects.push(Effect::FetchAppBilling);
}
// Fetch changelog (mirrors startup path for interactive login).
effects.push(Effect::FetchChangelog);
// ZDR-blocked users stay on the welcome screen — discard any
// deferred startup (they cannot start a session).
if app.is_zdr_blocked() {
clear_startup_actions(app);
return effects;
}
// Replay deferred session startup once BOTH gates are open. Auth
// is now Done, so `session_startup_allowed()` here means "is trust
// also resolved?" -- if trust is still Pending its question renders
@@ -1,532 +0,0 @@
//! Subscription tier checks, credit-limit upsells, and auto-topup handling.
use super::queue::maybe_drain_queue;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::AppView;
use crate::scrollback::block::RenderBlock;
use std::time::Duration;
/// How long the pager auto-checks subscription status before stopping.
/// After this, the user can still manually check via the [Refresh] button.
pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// Whether the user is at the highest subscription tier (SuperGrok Heavy).
///
/// Returns `true` only when `subscription_tier` **positively matches** a
/// known max-tier identifier. When the tier is unknown (`None`) or any
/// other value, returns `false` — the user gets the Q&A modal so lower-
/// tier users always see the upgrade option.
pub(super) fn is_max_tier(subscription_tier: Option<&str>) -> bool {
let Some(t) = subscription_tier else {
return false; // Unknown — default to Q&A.
};
// Normalize: lowercase + spaces→underscores to match both JWT-derived
// keys ("supergrok_heavy") and CCP display names ("SuperGrok Heavy").
t.to_ascii_lowercase().replace(' ', "_") == "supergrok_heavy"
}
/// URL for upgrading the subscription tier.
pub(crate) const UPSELL_URL_UPGRADE: &str = "https://grok.com/supergrok?referrer=grok-build";
/// URL for managing pay-as-you-go / on-demand spending / purchasing credits.
pub(crate) const UPSELL_URL_PAYG: &str = "https://grok.com?_s=usage";
/// Billing mode for credit-limit upsell copy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CreditLimitUpsellMode {
/// Unified usage pool — suggest purchasing prepaid credits.
UnifiedCredits,
/// Legacy on-demand / PAYG (`enabled` = on-demand cap already active).
LegacyPayg { enabled: bool },
}
/// Resolve upsell copy mode from credits config.
///
/// Prefers explicit `is_unified_billing_user` (`Option` — do not treat a
/// missing field as legacy). Positive `pay_as_you_go` (on-demand cap &gt; 0)
/// only selects legacy when the unified flag is absent. Unknown defaults to
/// unified (buy credits) so pool users never get “enable on-demand” wrongly.
pub(super) fn credit_limit_upsell_mode(
balance: Option<&crate::views::credit_bar::CreditBalance>,
) -> CreditLimitUpsellMode {
match balance {
Some(b) if b.is_unified_billing_user == Some(true) => CreditLimitUpsellMode::UnifiedCredits,
Some(b) if b.is_unified_billing_user == Some(false) => CreditLimitUpsellMode::LegacyPayg {
enabled: b.pay_as_you_go,
},
// Flag absent: only treat as legacy PAYG when we have a positive
// on-demand cap (pay_as_you_go is derived from cap &gt; 0).
Some(b) if b.pay_as_you_go => CreditLimitUpsellMode::LegacyPayg { enabled: true },
_ => CreditLimitUpsellMode::UnifiedCredits,
}
}
/// Whether an API / retry error is a credit-limit / spend-block denial.
///
/// - **402** Payment Required — always credit/spend block on this surface
/// (Build pool and IC spend blocks); no message filter.
/// - **403** — only when the body contains "run out of credits" (legacy IC
/// spend wording); other 403s (content-safety, ZDR, …) are excluded.
pub(crate) fn is_credit_limit_error(http_status: Option<u16>, message: &str) -> bool {
let m = message.to_ascii_lowercase();
let legacy = m.contains("run out of credits");
match http_status {
Some(402) => true,
Some(403) if legacy => true,
// Retry notifications embed "status 402" / "status 403" in the body
// without a separate status field.
None | Some(_) => m.contains("status 402") || (m.contains("status 403") && legacy),
}
}
/// Well-known error code CCP returns (HTTP 429, flat body
/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the
/// free usage quota. Kept in sync with the shared well-known error code
/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat
/// `code` to the flattened message, so the code reaches the pager embedded
/// in `RetryState::Exhausted.reason` and the -32003 error's data string.
pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted";
/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall)
/// rather than transient throttling. Text-sniff on the flattened message,
/// same precedent as [`is_credit_limit_error`].
pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool {
reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE)
}
/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion.
/// `data` may be a bare string or the `{message, promptUsage?}` object
/// `attach_prompt_usage` produces — always read via the shared detail helper.
pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool {
err.data
.as_ref()
.and_then(kigi_shell::sampling::error::error_detail_from_data)
.as_deref()
.is_some_and(is_free_usage_exhausted_error)
}
/// User-facing message for free-usage exhaustion. Shown by headless mode and
/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately
/// promises no reset duration — the quota window is backend-config-driven.
pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build";
/// Open the credit-limit upsell on the given agent.
///
/// **`max_tier = false`** (default): shows the Q&A question modal with
/// two options ("Upgrade tier" + buy-credits or PAYG). Each option's `id`
/// carries the target URL so the submit handler is position-independent.
///
/// **`max_tier = true`** (positively identified as SuperGrok Heavy):
/// pushes an inline scrollback card (`CreditLimitBlock`) with a single
/// continue action. No Q&A modal — the user can't upgrade further.
pub(super) fn open_credit_limit_upsell(
agent: &mut AgentView,
mode: CreditLimitUpsellMode,
max_tier: bool,
) {
use crate::scrollback::blocks::CreditLimitCardAction;
let (heading, upgrade_tier_desc, secondary_label, secondary_desc, card_action): (
&str,
&str,
&str,
&str,
CreditLimitCardAction,
) = match mode {
CreditLimitUpsellMode::UnifiedCredits => (
"You hit your weekly limit.",
"Upgrade to a higher tier for more usage",
"Buy more credits",
"Purchase credits to keep using Grok Build",
CreditLimitCardAction::PurchaseCredits,
),
CreditLimitUpsellMode::LegacyPayg { enabled: true } => (
"You\u{2019}ve hit your spending cap.",
"Upgrade to a higher tier for more credits",
"Increase limit",
"Raise your pay-as-you-go spending cap",
CreditLimitCardAction::IncreasePaygLimit,
),
CreditLimitUpsellMode::LegacyPayg { enabled: false } => (
"You\u{2019}ve hit the credit limit for your plan.",
"Upgrade to a higher tier for more credits",
"Pay as you go",
"Enable pay-as-you-go credits for on-demand usage",
CreditLimitCardAction::EnablePayg,
),
};
// ── Max tier: inline scrollback card ─────────────────────────
if max_tier {
use crate::scrollback::block::RenderBlock;
agent.scrollback.push_block(RenderBlock::credit_limit_card(
heading,
card_action,
UPSELL_URL_PAYG,
));
return;
}
// ── Default: Q&A question modal with two options ────────────────
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
if agent.question_view.is_some() {
return;
}
let question = Question {
question: heading.into(),
options: vec![
QuestionOption {
label: "Upgrade tier".into(),
description: upgrade_tier_desc.into(),
preview: None,
id: Some(UPSELL_URL_UPGRADE.into()),
},
QuestionOption {
label: secondary_label.into(),
description: secondary_desc.into(),
preview: None,
id: Some(UPSELL_URL_PAYG.into()),
},
],
multi_select: Some(false),
id: None,
};
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("credit-limit-upsell-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::CreditLimitUpsell)
.with_no_freeform();
agent.question_view = Some(state);
agent.prompt.set_text("");
}
/// Open the free-usage paywall on the given agent: a Q&A modal in the
/// [`open_credit_limit_upsell`] style with two upgrade options. Each
/// option's `id` carries its target URL so the submit handler is
/// position-independent.
///
/// Driver-only by construction (called from the PromptResponse handler,
/// which viewers never receive).
pub(super) fn open_free_usage_upsell(agent: &mut AgentView) {
open_supergrok_upsell(agent, UpsellReason::FreeUsageLimit);
}
/// Open the SuperGrok upsell for a tier-restricted slash command
/// (`/usage`, `/imagine`, …). Returns whether the modal opened (`false`
/// when another question modal is already up) so the caller can decide
/// whether to consume the input that triggered it.
pub(super) fn open_restricted_command_upsell(agent: &mut AgentView) -> bool {
open_supergrok_upsell(agent, UpsellReason::RestrictedCommand)
}
/// Which situation opened the SuperGrok upsell modal. Controls the heading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UpsellReason {
/// Free-usage quota exhausted (429 paywall).
FreeUsageLimit,
/// A tier-restricted slash command was invoked.
RestrictedCommand,
}
/// Shared builder behind [`open_free_usage_upsell`] /
/// [`open_restricted_command_upsell`]: a Q&A modal in the
/// [`open_credit_limit_upsell`] style. Upgrade options carry their target
/// URL in the option `id` (position-independent submit handling).
fn open_supergrok_upsell(agent: &mut AgentView, reason: UpsellReason) -> bool {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
// Never displace an already-open question modal. Callers that consume
// input on open must check this `false` and keep the input instead.
if agent.question_view.is_some() {
return false;
}
let (heading, modal_id_prefix) = match reason {
UpsellReason::FreeUsageLimit => ("You hit your free usage limit.", "free-usage-upsell"),
UpsellReason::RestrictedCommand => (
"Unlock all features with SuperGrok.",
"restricted-command-upsell",
),
};
let options = vec![
QuestionOption {
label: "Upgrade to SuperGrok".into(),
description: "For everyday coding and productivity tasks".into(),
preview: None,
id: Some(UPSELL_URL_UPGRADE.into()),
},
QuestionOption {
label: "Upgrade to SuperGrok Heavy".into(),
description: "Get the most out of Grok Build. Highest usage limits.".into(),
preview: None,
// No Heavy-specific URL exists; the /supergrok page lists
// both plans, so both upgrade options land there.
id: Some(UPSELL_URL_UPGRADE.into()),
},
];
let question = Question {
question: heading.into(),
options,
multi_select: Some(false),
id: None,
};
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("{modal_id_prefix}-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::FreeUsageUpsell)
.with_no_freeform();
agent.question_view = Some(state);
agent.prompt.set_text("");
true
}
/// Apply an [`AutoTopupFetch`] outcome to a cached `auto_topup` slot: `Resolved`
/// sets it, `Cleared` resets it to "unknown" (no credits), and `Unchanged` keeps
/// the last-known-good value (the fetch failed).
pub(super) fn apply_auto_topup(
slot: &mut Option<crate::views::credit_bar::AutoTopupInfo>,
fetch: &crate::views::credit_bar::AutoTopupFetch,
) {
use crate::views::credit_bar::AutoTopupFetch;
match fetch {
AutoTopupFetch::Resolved(rule) => *slot = Some(rule.clone()),
AutoTopupFetch::Cleared => *slot = None,
AutoTopupFetch::Unchanged => {}
}
}
// TaskResult handlers.
pub(super) fn handle_billing_fetched(
app: &mut AppView,
agent_id: AgentId,
balance: Option<crate::views::credit_bar::CreditBalance>,
silent: bool,
subscription_tier: Option<String>,
autotopup: crate::views::credit_bar::AutoTopupFetch,
) -> Vec<Effect> {
// Parse/transport failures route to `BillingError`, so a `None`
// balance here means the response carried no billing config. Clear
// the cached balance + polling so the status bar agrees with the
// "No billing data available." message rather than showing a stale
// value.
app.credit_balance = balance.clone();
// `Resolved` updates the cached rule, `Cleared` resets it to unknown
// (no credits), `Unchanged` keeps the last-known-good (fetch failed).
apply_auto_topup(&mut app.auto_topup, &autotopup);
app.billing_poll_wanted = balance
.as_ref()
.map(|b| b.usage_pct >= 99.0)
.unwrap_or(false);
if let Some(tier) = subscription_tier {
app.subscription_tier = Some(tier);
}
// Render the `/usage` summary from the now-current cached rule.
let summary_topup = app.auto_topup.clone();
if let Some(agent) = app.agents.get_mut(&agent_id) {
// Gateway/chat-kind: do not attach Build coding credits.
let mut topup = agent.auto_topup.clone();
apply_auto_topup(&mut topup, &autotopup);
agent.apply_credit_balance(balance.clone(), topup);
if !silent && !agent.chat_kind {
let msg = match &balance {
Some(bal) => {
crate::views::credit_bar::format_usage_summary(bal, summary_topup.as_ref())
}
None => "No billing data available.".to_string(),
};
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new(msg),
));
}
}
vec![]
}
pub(super) fn handle_gate_refreshed(
app: &mut AppView,
settings: Option<kigi_shell::util::config::RemoteSettings>,
) -> Vec<Effect> {
let Some(rs) = settings else {
return vec![];
};
app.usage_billing_redirect_url = rs.usage_billing_redirect_url.clone();
if let Some(secs) = rs.subscription_watch_interval_secs {
app.subscription_watch_interval_secs = Some(secs);
}
match AppView::gate_from_settings(&rs) {
Some(gate) => app.impose_gate(gate),
None => app.lift_gate(),
}
}
/// `x.ai/auth/check_subscription` completed. Meta is authoritative
/// (`apply_auth_meta` also drops any deferred gate). A failed check only
/// promotes the deferred gate it was verifying (`verify` generation);
/// generic watch/focus/paywall-chain failures never touch it.
pub(super) fn handle_check_subscription_complete(
app: &mut AppView,
verify: Option<u64>,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
let was_blocked = !app.has_access();
let applied = match meta {
Some(meta_val) => {
match serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val) {
Ok(auth_meta) => {
app.apply_auth_meta(&auth_meta);
true
}
Err(e) => {
// Shell sent meta we can't decode — a protocol bug, not
// a transient failure. The check result is lost, so a
// verify deferral falls through to promotion below.
crate::unified_log::error(
"subscription.check.meta_parse_failed",
None,
Some(serde_json::json!({
"verify": verify,
"error": e.to_string(),
})),
);
false
}
}
}
// meta: None = shell reports "not authenticated" or the check RPC
// failed (already logged as subscription.check.rpc_failed).
None => false,
};
if !applied && let Some(generation) = verify {
app.promote_deferred_gate(generation, "check_failed");
}
crate::unified_log::info(
"subscription.check.complete",
None,
Some(serde_json::json!({
"verify": verify,
"meta_applied": applied,
"was_blocked": was_blocked,
"gated": !app.has_access(),
"tier": app.subscription_tier,
})),
);
maybe_start_paywall_chain(app, was_blocked)
}
/// Safety net for a hung verification check: show the still-pending
/// deferred gate (err on blocking).
pub(super) fn handle_gate_verify_timeout(app: &mut AppView, generation: u64) -> Vec<Effect> {
let was_blocked = !app.has_access();
app.promote_deferred_gate(generation, "verify_timeout");
maybe_start_paywall_chain(app, was_blocked)
}
/// Arm the 5s paywall auto-check chain on an ungated→gated transition, so a
/// paywall shown by verify-before-paywall self-lifts exactly like the
/// login-path one. Guarded so steady-state paywall-poller responses and
/// repeated checks can't fan out extra timers.
fn maybe_start_paywall_chain(app: &mut AppView, was_blocked: bool) -> Vec<Effect> {
if !was_blocked && !app.has_access() && app.paywall_check_started.is_none() {
app.paywall_check_started = Some(std::time::Instant::now());
return vec![Effect::SchedulePaywallCheck];
}
vec![]
}
pub(super) fn handle_credit_limit_recheck_complete(
app: &mut AppView,
agent_id: AgentId,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
if let Some(meta_val) = meta
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
{
app.apply_auth_meta(&auth_meta);
}
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
// If the user already submitted another prompt while the
// recheck was in flight, don't show the upsell — they've moved on.
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
if !user_moved_on {
let balance = agent
.credit_balance
.as_ref()
.or(app.credit_balance.as_ref());
let mode = credit_limit_upsell_mode(balance);
let max_tier = is_max_tier(app.subscription_tier.as_deref());
open_credit_limit_upsell(agent, mode, max_tier);
}
// Either way, drop the stashed prompt.
agent.credit_limit_stashed_prompt = None;
let mut effects = maybe_drain_queue(agent);
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
effects
}
// Action handlers.
pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
let url = app
.gate
.as_ref()
.and_then(|g| g.url.as_deref())
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
// Funnel attribution: tag CLI-originated SuperGrok upsell clicks
// with `referrer=grok-build`, matching the OAuth consent flow and
// x.ai/cli marketing links. Applied even when the URL came from
// remote settings's `gate_url`, so we don't depend on the remote flag
// being correctly configured. If the URL already specifies a
// referrer it's left alone.
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
crate::app::link_opener::open_url(&url);
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_usage_dual_read_string_and_wrapped_object_data() {
let free = "subscription:free-usage-exhausted quota hit";
let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free);
assert!(acp_error_is_free_usage_exhausted(&string_err));
// attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}.
let wrapped =
agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({
"message": free,
"promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 }
}));
assert!(acp_error_is_free_usage_exhausted(&wrapped));
assert!(!wrapped.data.as_ref().unwrap().is_string());
let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled");
assert!(!acp_error_is_free_usage_exhausted(&other));
}
}
@@ -48,7 +48,7 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) {
state.adopt_slash_mru(app.slash_mru.clone());
state.set_screen_mode(app.screen_mode);
state.set_recap_visible(app.session_recap_available);
state.set_restricted_commands(&app.tier_restricted_commands);
state.set_restricted_commands(&[]);
app.dashboard = Some(state);
}
@@ -144,7 +144,7 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
// Subsequent reopen — just gc dead ids; in-memory state stays.
d.gc_stale_refs(&dashboard_alive_fn(&app.agents));
d.set_recap_visible(app.session_recap_available);
d.set_restricted_commands(&app.tier_restricted_commands);
d.set_restricted_commands(&[]);
}
// Refresh each local agent's git context (branch / worktree / label)
// from disk so the row subtitles show the LATEST branch and worktree
@@ -1207,7 +1207,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
return vec![];
}
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@@ -1231,23 +1230,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
};
let reg = dashboard.dispatch.slash_controller.registry();
// Tier-restricted commands stay visible for discoverability but must
// not execute — and must not fall through to the unknown-command
// path below (which would spawn a session with the raw slash text as
// its first prompt). The dashboard has no question-modal surface, so
// upsell via the feedback toast.
if reg.is_restricted(invocation.token) {
let token = invocation.token.to_string();
if let Some(d) = app.dashboard.as_mut() {
d.dispatch.set_text("");
d.set_error_toast(&format!(
"/{token} requires SuperGrok — upgrade at {}",
super::billing::UPSELL_URL_UPGRADE
));
}
return vec![];
}
let Some(command) = reg.get(invocation.token).cloned() else {
// Unknown command. Fall back to the regular dispatch
// path so the text becomes a new session's prompt.
@@ -1295,7 +1277,6 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
.iter()
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
plan_mode_active: false,
show_tips: show_tips_from_app,
auto_update: auto_update_from_app,
@@ -13,7 +13,6 @@
//! otherwise); tests/ shares a fixture prelude via `use super::*;`.
mod auth;
mod billing;
mod ctx;
mod dashboard;
mod import_claude;
@@ -33,10 +32,6 @@ mod task_result;
mod transcript;
mod turn;
pub(crate) use billing::{
FREE_USAGE_USER_MESSAGE, UPSELL_URL_PAYG, UPSELL_URL_UPGRADE,
acp_error_is_free_usage_exhausted, is_credit_limit_error, is_free_usage_exhausted_error,
};
pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto};
pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages};
pub(crate) use permissions::resolve_permission_queue_transition;
@@ -1,7 +1,6 @@
//! Prompt and bash-command submission dispatchers and reload-window helpers.
use super::auth::{scrollback_has_recent_context_too_large, scrollback_has_recent_reauth_prompt};
use super::billing::is_credit_limit_error;
use super::ctx::with_active_agent;
use super::interject;
use super::permissions::drain_permission_queue;
@@ -292,7 +291,6 @@ pub(super) fn dispatch_send_prompt_inner(
return vec![];
};
// Capture app-level fields before the mut-borrow on `agent`.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@@ -324,37 +322,6 @@ pub(super) fn dispatch_send_prompt_inner(
let mut effects = Vec::new();
// ── Tier-restricted command upsell ─────────────────────────────
// Restricted commands (`/usage`, `/imagine`, …) are hidden from the
// registry's `get()`, so a typed invocation would otherwise fall
// through the unknown-command path below and leak to the model as a
// raw prompt. Upsell instead; genuinely unknown commands still pass
// through (shell/ACP commands depend on that).
if !literal
&& trimmed.starts_with('/')
&& let Some(invocation) = crate::slash::parse_invocation(trimmed)
&& agent
.prompt
.slash_controller
.registry()
.is_restricted(invocation.token)
{
// Only consume the composer when the upsell can actually open: with
// another question modal already up, `open_supergrok_upsell` would
// no-op and wiping the composer here would silently drop the typed
// text. Keep it instead so the user can resubmit after closing the
// modal — and never fall through to passthrough for restricted
// commands.
if agent.question_view.is_none() {
if consume_input {
agent.prompt.set_text("");
}
let opened = super::billing::open_restricted_command_upsell(agent);
debug_assert!(opened, "no modal was open, so the upsell must open");
}
return vec![];
}
// ── Registry-based slash command execution ─────────────────────
// If the text starts with `/`, run it through the slash registry.
// The registry resolves builtins, ACP-advertised commands, and
@@ -384,7 +351,6 @@ pub(super) fn dispatch_send_prompt_inner(
.iter()
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@@ -1004,29 +970,11 @@ pub(super) fn handle_prompt_response(
None => expected_send_now.is_some(),
};
let rate_limited = agent.session.rate_limited;
// Fallback mirroring the credit-limit race guard below: if the retry
// notification lost the race with (or never reached) this
// PromptResponse, detect the free-usage code from the prompt error
// itself — the flattened 429 body embeds it.
let free_usage_blocked = agent.session.free_usage_blocked
|| result
.as_ref()
.err()
.is_some_and(|e| super::billing::is_free_usage_exhausted_error(e));
let model_incompatible = agent.session.model_incompatible;
// Context overflow: the RetryState handler already pushed the actionable
// block, so the generic TurnFailed + error toast are redundant. Derived
// from the scrollback (mirrors reauth), not a session flag.
let context_overflow = scrollback_has_recent_context_too_large(&agent.scrollback);
// Fallback: if the retry notification didn't set the flag,
// detect credit-limit denials (legacy 403 or pool 402) from
// the PromptResponse error + HTTP status. Covers races where
// the retry notification arrives after the PromptResponse.
let credit_limit_blocked = agent.session.credit_limit_blocked
|| result
.as_ref()
.err()
.is_some_and(|e| is_credit_limit_error(http_status, e));
// A 401/auth failure already surfaced an actionable
// `ReAuthRequired` prompt via the RetryState handler (which
// runs before this PromptResponse). Suppress the redundant
@@ -1055,12 +1003,7 @@ pub(super) fn handle_prompt_response(
);
}
// Stash the complete in-flight prompt before finish_turn clears it.
// Used by CreditLimitRecheckComplete to retry after a tier upgrade.
if credit_limit_blocked {
agent.credit_limit_stashed_prompt = agent.session.in_flight_prompt.clone();
}
// Likewise, stash the prompt from a turn that failed on an
// Stash the prompt from a turn that failed on an
// expired login (401 / re-auth). The AuthComplete handler
// auto-resubmits it after a successful mid-session re-auth.
// A non-rewindable turn (None) must not clobber an earlier stash.
@@ -1110,17 +1053,11 @@ pub(super) fn handle_prompt_response(
elapsed: Some(elapsed.unwrap_or_default()),
}),
(Err(_), _)
if rate_limited
|| free_usage_blocked
|| model_incompatible
|| credit_limit_blocked
|| reauth_prompted
|| context_overflow =>
if rate_limited || model_incompatible || reauth_prompted || context_overflow =>
{
// Skip TurnFailed when a dedicated prompt/modal shows instead
// (rate limit, free-usage paywall, model incompatibility,
// credit 403, 401 re-auth, or a terminal context-window
// overflow).
// (rate limit, model incompatibility, 401 re-auth, or a
// terminal context-window overflow).
None
}
(Err(err), _) => Some(SessionEvent::TurnFailed {
@@ -1147,9 +1084,7 @@ pub(super) fn handle_prompt_response(
}
(Err(err), _)
if !rate_limited
&& !free_usage_blocked
&& !model_incompatible
&& !credit_limit_blocked
&& !reauth_prompted
&& !context_overflow =>
{
@@ -1256,7 +1191,7 @@ pub(super) fn handle_prompt_response(
// Predicted-next-prompt (tab autocomplete): wipe any stale suggestion
// at every turn boundary. This must run before the reconnect /
// credit-limit early returns below, which skip the fetch gate
// paywall early returns below, which skip the fetch gate
// entirely — a prior ghost would otherwise survive those paths.
agent.prompt.prompt_suggestion.clear();
@@ -1271,60 +1206,6 @@ pub(super) fn handle_prompt_response(
return vec![];
}
// Credit-limit (403 legacy / 402 pool): strip stale error
// blocks, then do a one-shot subscription re-check. If the
// tier changed (user upgraded mid-session), the stashed
// prompt is retried automatically; otherwise the upsell
// is shown.
if credit_limit_blocked {
// Strip stale "Retry failed" / "Turn failed" error blocks
// that were pushed before the credit-limit was detected.
// Walk backwards from the end and remove matching events.
let mut to_remove = Vec::new();
for idx in (0..agent.scrollback.len()).rev() {
match agent.scrollback.entry(idx).map(|e| &e.block) {
Some(crate::scrollback::block::RenderBlock::SessionEvent(ev))
if matches!(
&ev.event,
SessionEvent::RetryFailed { .. } | SessionEvent::TurnFailed { .. }
) =>
{
to_remove.push(idx);
}
// Stop at the first non-error block.
Some(
crate::scrollback::block::RenderBlock::SessionEvent(_)
| crate::scrollback::block::RenderBlock::System(_),
) => continue,
_ => break,
}
}
for idx in to_remove {
agent.scrollback.remove_from(idx);
}
// Defer the upsell until the subscription re-check
// completes. Queue drain + billing fetch happen in the
// CreditLimitRecheckComplete handler.
if let Some(p) = pending_adoption {
agent.discard_pending_adoption_updates(&p.prompt_id);
}
return vec![Effect::CreditLimitRecheck { agent_id }];
}
// Free-usage paywall (429 + subscription:free-usage-exhausted): the
// RetryState handler set the flag and suppressed the generic
// rate-limit block; show the upsell modal. Driver-only by
// construction — viewers never receive a PromptResponse. No queue
// drain: queued prompts would fail on the same exhausted quota.
if free_usage_blocked {
super::billing::open_free_usage_upsell(agent);
if let Some(p) = pending_adoption {
agent.discard_pending_adoption_updates(&p.prompt_id);
}
return vec![];
}
// FIFO handoff: if a server-authoritative prompt drained
// into the running slot during this turn's teardown, adopt it
// now (finish_turn cleared current_prompt_id) and run the
@@ -1372,10 +1253,6 @@ pub(super) fn handle_prompt_response(
});
}
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
return effects;
}
vec![]
@@ -876,13 +876,9 @@ mod tests {
kind: crate::app::agent::QueueEntryKind::Prompt,
};
// Turn ends → should NOT drain "second" (user is editing it), only FetchBilling.
// Turn ends → should NOT drain "second" (user is editing it).
let effects = dispatch(end_turn(), &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
// "second" should still be in the queue.
assert_eq!(app.agents[&id].session.queue_len(), 2);
@@ -908,14 +904,10 @@ mod tests {
kind: crate::app::agent::QueueEntryKind::Prompt,
};
// Turn ends → should drain "second" (front, not being edited) + FetchBilling.
// Turn ends → should drain "second" (front, not being edited).
let effects = dispatch(end_turn(), &mut app);
assert_eq!(effects.len(), 2);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
));
// "third" should still be in queue.
assert_eq!(app.agents[&id].session.queue_len(), 1);
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "third");
@@ -1809,13 +1801,9 @@ mod tests {
kind: crate::app::agent::QueueEntryKind::Prompt,
};
// End turn for p2 → should NOT drain p3 (being edited), only FetchBilling.
// End turn for p2 → should NOT drain p3 (being edited).
let effects = dispatch(end_turn(), &mut app);
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::FetchBilling { silent: true, .. }),
"drain should be blocked, only billing refresh"
);
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
assert_eq!(app.agents[&id].session.queue_len(), 2); // p3, p4
// Simulate user saving edited text.
@@ -3,7 +3,6 @@ use super::auth::{
dispatch_cancel_login, dispatch_login, dispatch_logout, dispatch_submit_auth_code,
dispatch_switch_account,
};
use super::billing::dispatch_open_supergrok_url;
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
sync_sleep_inhibitor, with_active_agent, with_scrollback,
@@ -93,10 +92,9 @@ use super::settings::ui::{
dispatch_toggle_vim_mode,
};
use super::status::{
dispatch_copy_session_id, dispatch_open_gboom, dispatch_share_session,
dispatch_show_context_info, dispatch_show_privacy_info, dispatch_show_queue,
dispatch_copy_session_id, dispatch_open_gboom, dispatch_show_context_info, dispatch_show_queue,
dispatch_show_release_notes, dispatch_show_session_info, dispatch_show_tasks,
dispatch_show_usage, set_coding_data_sharing,
dispatch_show_usage,
};
use super::task_result::{dispatch_task_result, unregister_all_active_sessions};
use super::transcript::{
@@ -551,23 +549,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
if group_toggled {
return vec![];
}
let mut credit_card: Option<String> = None;
with_scrollback(app, |s| {
if let Some(idx) = s.selected()
&& let Some(entry) = s.entry(idx)
&& let crate::scrollback::block::RenderBlock::CreditLimit(ref blk) = entry.block
{
credit_card = Some(blk.url.clone());
}
});
if let Some(url) = credit_card {
crate::app::link_opener::open_url_if_safe(
&url,
crate::terminal::hyperlinks::SchemeFilter::Standard,
);
} else {
dispatch_open_block_viewer(app);
}
dispatch_open_block_viewer(app);
vec![]
}
Action::OpenExtensionsModal { tab } => {
@@ -789,7 +771,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
vec![Effect::FetchCatalogEntry { kind, name }]
}
Action::CycleMode => dispatch_cycle_mode(app),
Action::ShareSession => dispatch_share_session(app),
Action::ShowSessionInfo => dispatch_show_session_info(app),
Action::ShowReleaseNotes { title, content } => {
dispatch_show_release_notes(app, title, content)
@@ -809,8 +790,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
Action::SendBtw(question) => dispatch_send_btw(app, question),
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
Action::ShowPrivacyInfo => dispatch_show_privacy_info(app),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
Action::ToggleYolo => dispatch_toggle_yolo(app),
Action::ToggleMultiline => dispatch_toggle_multiline(app),
Action::ToggleCompactMode => dispatch_toggle_compact_mode(app),
@@ -873,8 +852,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::PermissionCancel => dispatch_permission_cancel(app),
Action::Logout => dispatch_logout(app),
Action::SwitchAccount => dispatch_switch_account(app),
Action::CheckSubscription => vec![Effect::CheckSubscription { verify: None }],
Action::OpenSupergrokUrl => dispatch_open_supergrok_url(app),
Action::OpenUrl(url) => {
use crate::terminal::hyperlinks::SchemeFilter;
if url.starts_with("file://") {
@@ -894,7 +871,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
}
Action::OpenManagedConnectors => {
use crate::terminal::hyperlinks::SchemeFilter;
let url = crate::views::mcps_modal::managed_connectors_url(app.team_id.as_deref());
let url = crate::views::mcps_modal::managed_connectors_url(None);
crate::app::link_opener::open_url_if_safe(&url, SchemeFilter::Standard);
vec![]
}
@@ -1,7 +1,6 @@
use crate::app::actions::Effect;
use crate::app::app_view::{AppView, SessionPickerEntry};
use crate::app::dispatch::ctx::get_active_agent_mut;
use crate::app::effects::ConversationsPartial;
use crate::views::modal::ActiveModal;
use crate::views::picker::PickerState;
use crate::views::session_picker::{
@@ -61,7 +60,6 @@ impl PickerSurface<'_> {
query: Option<String>,
chat_mode: bool,
empty_notice: String,
partial_notice: Option<&'static str>,
) -> Option<String> {
let anchor = self.capture_selection();
let is_search = query.is_some();
@@ -88,11 +86,7 @@ impl PickerSurface<'_> {
}
} else {
self.lanes.pending_notice = None;
if chat_mode {
partial_notice.map(str::to_owned)
} else {
None
}
None
};
self.restore_selection(anchor);
notice
@@ -188,7 +182,6 @@ pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -
pub(in crate::app::dispatch) fn handle_session_list_loaded(
app: &mut AppView,
sessions: Vec<SessionPickerEntry>,
partial: Option<ConversationsPartial>,
seq: u64,
query: Option<String>,
) -> Vec<Effect> {
@@ -196,18 +189,7 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
return vec![];
}
app.session_picker_detail_generation += 1;
if let Some(partial) = partial {
crate::unified_log::warn(
"session.list.partial",
None,
Some(serde_json::json!({ "reason": format!("{partial:?}") })),
);
}
let empty_notice = partial.map_or_else(
|| "No sessions found for this directory".to_owned(),
|partial| partial.picker_notice().to_owned(),
);
let partial_notice = partial.map(ConversationsPartial::picker_notice);
let empty_notice = "No sessions found for this directory".to_owned();
let chat_mode = app.chat_mode;
let mut sessions = Some(sessions);
let mut notice = None;
@@ -242,7 +224,6 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
query.clone(),
chat_mode,
empty_notice.clone(),
partial_notice,
);
}
}
@@ -260,7 +241,7 @@ pub(in crate::app::dispatch) fn handle_session_list_loaded(
grouped: app.session_picker_grouped,
current_repo,
}
.native_loaded(sessions, query, chat_mode, empty_notice, partial_notice);
.native_loaded(sessions, query, chat_mode, empty_notice);
}
if let Some(notice) = notice {
app.show_toast(&notice);
@@ -206,15 +206,8 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved(
.prompt
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
agent.set_session_recap_available(app.session_recap_available);
agent.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
agent.chat_kind = parent_chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
.slash_controller
@@ -353,7 +346,6 @@ pub(in crate::app::dispatch) fn dispatch_project_selected(
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&id) {
agent.chat_kind = chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
}
effects.push(Effect::CreateSession {
agent_id: id,
@@ -398,8 +390,6 @@ fn build_fork_placeholder(
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
@@ -543,7 +533,6 @@ pub(in crate::app::dispatch) fn handle_worktree_forked(
}
let effective_chat = conversation_entry || app.chat_mode;
agent.chat_kind = effective_chat;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
return vec![Effect::LoadSession {
agent_id,
session_id: session_id_str,
@@ -304,8 +304,6 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
@@ -330,14 +328,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
.prompt
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
agent.set_session_recap_available(app.session_recap_available);
agent.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
agent
.prompt
.slash_controller
@@ -353,7 +344,6 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.chat_kind = chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.mcp_init_progress = Some(McpInitProgress {
total: 0,
connected: 0,
@@ -402,7 +392,7 @@ pub(in crate::app::dispatch) fn dispatch_trust_folder(app: &mut AppView) -> Vec<
/// `AuthComplete` uses, so whichever gate resolves last drains exactly once.
pub(in crate::app::dispatch) fn finish_trust(app: &mut AppView) -> Vec<Effect> {
app.trust_state = TrustState::Done;
app.welcome_prompt_focused = !app.is_access_blocked();
app.welcome_prompt_focused = true;
if app.session_startup_allowed() {
drain_startup_actions(app)
} else {
@@ -629,8 +619,6 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
@@ -667,15 +655,8 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
.prompt
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
agent.set_session_recap_available(app.session_recap_available);
agent.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
agent.chat_kind = chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
.slash_controller
@@ -764,7 +745,6 @@ pub(in crate::app::dispatch) fn skip_picker_and_create_session(
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.chat_kind = chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.mcp_init_progress = Some(McpInitProgress {
total: 0,
connected: 0,
@@ -836,10 +816,6 @@ pub(in crate::app::dispatch) fn handle_session_created(
session_id: session_id_clone.clone(),
});
effects.push(Effect::RefreshAvailableCommands { agent_id, cwd });
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
if let Some((model_id, effort)) = deferred {
effects.push(Effect::SwitchModel {
agent_id,
@@ -916,10 +892,6 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created(
session_id: session_id_clone.clone(),
});
effects.push(Effect::RefreshAvailableCommands { agent_id, cwd });
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
if let Some((model_id, effort)) = deferred {
effects.push(Effect::SwitchModel {
agent_id,
@@ -162,8 +162,6 @@ fn dispatch_load_session_ungated(
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
@@ -195,15 +193,8 @@ fn dispatch_load_session_ungated(
agent_mut.session.start_command(AgentCommand::RestoreCode);
agent_mut.turn_started_at = Some(std::time::Instant::now());
}
agent_mut.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent_mut.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
agent_mut.chat_kind = chat_kind || app.chat_mode;
agent_mut.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent_mut
.prompt
.slash_controller
@@ -808,8 +799,6 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
@@ -836,15 +825,8 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
.prompt
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
agent.set_session_recap_available(app.session_recap_available);
agent.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
agent.chat_kind = app.chat_mode;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
.slash_controller
@@ -958,10 +940,6 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
agent_id,
session_id: hydrate_sid.clone(),
});
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
if let Some((model_id, effort)) = deferred {
agent.session.model_switch_pending = true;
effects.push(Effect::SwitchModel {
@@ -1110,7 +1088,6 @@ pub(in crate::app::dispatch) fn handle_session_restored(
supersede_open_reload_window(agent, agent_id, "SessionRestored");
agent.bind_session_id(sid);
agent.chat_kind = app.chat_mode;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent.scrollback.push_block(RenderBlock::system(format!(
"Session restored. Loading {local_session_id}..."
)));
@@ -44,7 +44,6 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
}
let ui_snapshot = app.current_ui.clone();
// Capture app-level fields before the mut-borrow loop.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@@ -76,7 +75,6 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
.iter()
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@@ -110,7 +108,7 @@ pub(in crate::app::dispatch) fn dispatch_open_command_palette(app: &mut AppView)
return vec![];
}
agent.active_modal = Some(ActiveModal::CommandPalette {
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
entries: crate::views::modal::default_palette_entries(),
// Type-to-find: open in input mode (matches Ctrl+P).
state: crate::views::picker::PickerState::input_active(),
window: crate::views::modal_window::ModalWindowState::new(),
@@ -149,7 +147,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
let registry = app.settings_registry.clone();
let ui_snapshot = app.current_ui.clone();
// Capture app-level fields before the mut-borrow on the agent.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@@ -187,7 +184,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
.iter()
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@@ -657,7 +653,6 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
auto_mode: agent_auto_mode(app),
current_model_name: agent_current_model_name(app),
available_models: agent_available_models(app),
coding_data_sharing_opt_out: app.coding_data_retention_opt_out,
plan_mode_active: agent_plan_mode(app),
show_tips: app.show_tips,
auto_update: app.auto_update,
@@ -793,14 +788,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
}
// max_thoughts_width: direct round-trip.
("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)),
// coding_data_sharing: "opt-in" / "opt-out" → bool.
// "opt-out" arm is a skew guard (default is "opt-in").
("coding_data_sharing", SettingValue::Enum("opt-in")) => {
Some(Action::SetCodingDataSharing { opted_in: true })
}
("coding_data_sharing", SettingValue::Enum("opt-out")) => {
Some(Action::SetCodingDataSharing { opted_in: false })
}
// plan_mode: "on" / "off" → PlanModeKind.
// "on" arm is a skew guard (default is "off").
("plan_mode", SettingValue::Enum("off")) => {
@@ -1,7 +1,6 @@
//! Session status, sharing, privacy, usage, and info dispatchers.
//! Session status, privacy, usage, and info dispatchers.
use super::ctx::get_active_agent;
use super::settings::ui::refresh_open_settings_modals;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
@@ -9,39 +8,6 @@ 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.
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,
}]
}
/// Show session info: fetch via x.ai/session/info and display in scrollback.
///
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
@@ -66,131 +32,6 @@ pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec<Effect> {
}]
}
/// Show privacy and data retention status as a system message in scrollback.
///
/// Three-state display: Enterprise ZDR, coding data sharing opted out,
/// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in
/// `settings/defs.rs` and the `coding_data_sharing_toast` format.
pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec<Effect> {
let mut lines = Vec::new();
if app.is_zdr {
// Enterprise ZDR -- the team has disabled retention entirely.
lines.push(" Zero Data Retention: enabled");
lines.push(" Your data is not retained or used for training (ZDR enabled).");
} else if app.coding_data_retention_opt_out {
// Coding data sharing opted out -- matches desktop's "Privacy mode" state.
lines.push(" Privacy: privacy mode");
lines.push(" Your code data will not be trained on or used to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-in to share data and help improve the product.");
} else {
// Coding data sharing opted in -- matches desktop's "Share data" state.
lines.push(" Privacy: share data");
lines.push(" Usage and code data may be used by SpaceXAI to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-out to enable privacy mode.");
}
lines.push("");
lines.push(" Learn more: https://x.ai/legal");
let text = lines.join("\n");
push_system_to_any_agent(app, &text);
vec![]
}
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
app.coding_data_retention_opt_out = !opted_in;
}
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
/// (persists via ACP ext-request, NOT `~/.kigi/config.toml`).
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
if app.is_zdr {
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
return vec![];
}
// ── Guard 2: Non-admin team member ───────────────────────────────
if app.team_name.is_some() {
let is_admin = app
.team_role
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case("admin"));
if !is_admin {
app.show_toast("\u{2717} Data sharing is controlled by your team admin");
return vec![];
}
}
// ── Guard 3: an agent must exist to thread the ACP call through ──
let agent_id = match app.active_view {
crate::app::app_view::ActiveView::Agent(id) => id,
_ => match app.agents.keys().next().copied() {
Some(id) => id,
None => {
tracing::warn!(
target: "settings",
key = "coding_data_sharing",
opted_in,
"set_coding_data_sharing called with no agents — unreachable in \
practice; returning empty (no toast: app.show_toast would no-op)",
);
return vec![];
}
},
};
let prev = !app.coding_data_retention_opt_out;
// ── Idempotent path: toast but skip the ACP round-trip. ──────────
if prev == opted_in {
app.show_toast(&coding_data_sharing_toast(opted_in));
return vec![];
}
// ── Optimistic mutation: state, then UI feedback, then effect. ───
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
key = "coding_data_sharing",
opted_in,
"setting changed",
);
vec![Effect::SetCodingDataSharing {
agent_id,
opted_in,
rollback_to_opted_in: prev,
}]
}
/// Format the `Coding data sharing` toast. Asymmetric: opt-in
/// (privacy-degrading) uses ⚠ + consequence text; opt-out (safe
/// default) uses ✓. Uses display names from the registry catalog.
pub(super) fn coding_data_sharing_toast(opted_in: bool) -> String {
let display = display_for_coding_data_sharing_canonical(opted_in);
if opted_in {
// Privacy-degrading: warn glyph + spelled-out consequence.
format!(
"\u{26A0} Coding data sharing: {display} \u{2014} code samples may be retained \
for training"
)
} else {
// Safe default — uniform ✓ glyph.
format!("\u{2713} Coding data sharing: {display}")
}
}
/// Display string for the canonical bool. Keep aligned with
/// `CODING_DATA_SHARING_CHOICES` in `settings/defs.rs`.
fn display_for_coding_data_sharing_canonical(opted_in: bool) -> &'static str {
if opted_in { "Opt in" } else { "Opt out" }
}
/// Scrub an untrusted error string for toast display. Substitutes a
/// generic placeholder when the input exceeds 120 chars or contains
/// control / bidi-override characters (prevents escape-sequence
@@ -208,21 +49,6 @@ pub(super) fn scrub_error_for_toast(error: &str) -> String {
}
}
/// Push a system message to the active agent's scrollback, or to any available
/// agent if on the welcome screen.
fn push_system_to_any_agent(app: &mut AppView, msg: &str) {
let block = crate::scrollback::block::RenderBlock::system(msg.to_string());
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.scrollback.push_block(block);
return;
}
if let Some(agent) = app.agents.values_mut().next() {
agent.scrollback.push_block(block);
}
}
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
///
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
@@ -244,32 +70,73 @@ pub(super) fn dispatch_show_context_info(app: &mut AppView) -> Vec<Effect> {
}]
}
/// Show credit usage: fetch billing data and display inline.
/// `/usage` — fetch Kimi usage/quota rows and display them inline.
///
/// When the remote settings `grok_build_usage_redirect_url` flag is set (delivered via
/// RemoteSettings, targeted at personal-team users), skip the backend fetch and
/// just point the user at that URL instead. This is a kill switch for the
/// personal-team billing path while it is unreliable.
/// Produces [`Effect::FetchUsage`], which asks the shell's `x.ai/billing`
/// extension (`GET {base}/usages`); [`handle_usage_fetched`] renders the
/// rows as a system block in scrollback.
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
if let Some(url) = app.usage_billing_redirect_url.clone() {
if let Some(agent) = app.agents.get_mut(&id) {
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new(format!(
"Please check your usage on {url}"
)),
));
}
return vec![];
vec![Effect::FetchUsage { agent_id: id }]
}
/// Render the `/usage` result: the fetched quota rows (kimi-cli
/// `usage.py` semantics — label, remaining-quota bar, percent left, reset
/// hint), "No usage data available." for an empty list, or the error.
pub(super) fn handle_usage_fetched(
app: &mut AppView,
agent_id: AgentId,
result: Result<Vec<kigi_shell::extensions::billing::UsageRow>, String>,
) -> Vec<Effect> {
let msg = match &result {
Ok(rows) if rows.is_empty() => "No usage data available.".to_string(),
Ok(rows) => format_usage_rows(rows),
Err(e) => format!("Couldn't fetch usage: {e}"),
};
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.scrollback.push_block(RenderBlock::system(msg));
}
// Non-silent fetch: the effect also pulls the auto top-up rule so the
// summary can render usage, prepaid credits, and auto top-up together.
vec![Effect::FetchBilling {
agent_id: id,
silent: false,
}]
vec![]
}
/// Width of the remaining-quota bar, matching kimi-cli's usage panel.
const USAGE_BAR_WIDTH: usize = 20;
/// Format usage rows as aligned text lines (kimi-cli `_format_row`
/// parity): `label [bar] N% left (reset hint)`. The percentage is
/// derived from `used`/`limit` only — a row without a positive limit
/// renders as 0% left with an empty bar, exactly like kimi-cli.
fn format_usage_rows(rows: &[kigi_shell::extensions::billing::UsageRow]) -> String {
let label_width = rows
.iter()
.map(|r| r.label.chars().count())
.max()
.unwrap_or(0)
.max(6);
let mut lines = vec!["API Usage".to_string()];
for row in rows {
let ratio = if row.limit <= 0 {
0.0
} else {
(row.limit - row.used).clamp(0, row.limit) as f64 / row.limit as f64
};
let filled = (ratio * USAGE_BAR_WIDTH as f64).round() as usize;
let filled = filled.min(USAGE_BAR_WIDTH);
let bar: String = "\u{2588}".repeat(filled) + &"\u{2591}".repeat(USAGE_BAR_WIDTH - filled);
let mut line = format!(
" {:<width$} [{bar}] {:.0}% left",
row.label,
ratio * 100.0,
width = label_width,
);
if let Some(hint) = &row.reset_hint {
line.push_str(&format!(" ({hint})"));
}
lines.push(line);
}
lines.join("\n")
}
/// Commit a one-line "update available" notice into the active agent's
@@ -366,58 +233,6 @@ pub(super) fn notify_session_ready(
// TaskResult handlers.
pub(super) fn handle_coding_data_sharing_updated(
app: &mut AppView,
agent_id: AgentId,
opted_in: bool,
) -> Vec<Effect> {
// Re-anchor mirror to server-confirmed value (defense-in-
// depth against server reshaping the boolean). `agent_id`
// discarded — privacy is app-level, not per-agent.
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
// Re-toast on confirmation. Without this, a slow ACP
// round-trip would leave the user with only the
// optimistic toast (already faded) and no
// server-confirmed feedback.
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
key = "coding_data_sharing",
?agent_id,
opted_in,
"ACP update confirmed; mirror re-anchored",
);
vec![]
}
pub(super) fn handle_coding_data_sharing_failed(
app: &mut AppView,
agent_id: AgentId,
error: String,
rollback_to_opted_in: bool,
) -> Vec<Effect> {
// Revert optimistic mutation: inner → refresh → toast.
//
// `agent_id` discarded — privacy is global.
set_coding_data_sharing_inner(app, rollback_to_opted_in);
refresh_open_settings_modals(app);
// Scrub long/unsafe error strings before toasting.
let scrubbed = scrub_error_for_toast(&error);
app.show_toast(&format!(
"\u{2717} Couldn't update coding data sharing: {scrubbed}"
));
tracing::warn!(
target: "settings",
key = "coding_data_sharing",
?agent_id,
rollback_to_opted_in,
%error,
"ACP update failed; reverted optimistic mutation",
);
vec![]
}
pub(super) fn handle_context_info_complete(
app: &mut AppView,
agent_id: AgentId,
@@ -2,11 +2,6 @@
use super::auth::{
ensure_login_method, handle_auth_complete, handle_auth_url_ready, handle_mcp_auth_trigger_done,
};
use super::billing::{
PAYWALL_AUTO_CHECK_TIMEOUT, apply_auto_topup, handle_billing_fetched,
handle_check_subscription_complete, handle_credit_limit_recheck_complete,
handle_gate_refreshed, handle_gate_verify_timeout,
};
use super::ctx::{find_agent_by_session_id, get_active_agent_mut};
use super::notes::{handle_btw_response, handle_memory_note_saved};
use super::prompt::{
@@ -34,10 +29,7 @@ use super::session::load::{
handle_session_search_debounce_expired, remove_session_from_pickers,
};
use super::settings::ui::apply_setting_rollback;
use super::status::{
handle_coding_data_sharing_failed, handle_coding_data_sharing_updated,
handle_context_info_complete, scrub_error_for_toast,
};
use super::status::{handle_context_info_complete, handle_usage_fetched, scrub_error_for_toast};
use super::transcript::{
handle_hooks_list_loaded, handle_mcp_toggle_done, handle_plugins_list_loaded,
handle_skills_toggle_done,
@@ -224,33 +216,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
TaskResult::ForkSessionFailed { agent_id, error } => {
handle_fork_session_failed(app, agent_id, error)
}
TaskResult::BillingFetched {
agent_id,
balance,
silent,
subscription_tier,
autotopup,
} => handle_billing_fetched(app, agent_id, balance, silent, subscription_tier, autotopup),
TaskResult::BillingError {
agent_id,
error,
silent,
} => {
if !silent && let Some(agent) = app.agents.get_mut(&agent_id) {
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new(format!(
"Billing error: {error}"
)),
));
}
vec![]
TaskResult::UsageFetched { agent_id, result } => {
handle_usage_fetched(app, agent_id, result)
}
TaskResult::AppBillingFetched { balance, autotopup } => {
app.credit_balance = balance;
apply_auto_topup(&mut app.auto_topup, &autotopup);
vec![]
}
TaskResult::GateRefreshed { settings } => handle_gate_refreshed(app, settings),
TaskResult::SessionLoaded {
agent_id,
session_id,
@@ -287,10 +255,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
} => handle_session_load_failed(app, agent_id, session_id, error),
TaskResult::SessionListLoaded {
sessions,
partial,
seq,
query,
} => handle_session_list_loaded(app, sessions, partial, seq, query),
} => handle_session_list_loaded(app, sessions, seq, query),
TaskResult::ForeignSessionsScanned { entries, seq } => {
handle_foreign_sessions_scanned(app, entries, seq)
}
@@ -606,29 +573,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
TaskResult::SkillsToggleDone { agent_id, result } => {
handle_skills_toggle_done(app, agent_id, result)
}
TaskResult::ShareSessionComplete {
agent_id,
share_url,
} => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Session shared: {share_url}"
)));
}
vec![]
}
TaskResult::ShareSessionFailed { agent_id, error } => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Couldn't share session: {error}"
)));
}
vec![]
}
TaskResult::SessionAgentNameResolved {
agent_id,
agent_name,
@@ -668,14 +612,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
}
vec![]
}
TaskResult::CodingDataSharingUpdated { agent_id, opted_in } => {
handle_coding_data_sharing_updated(app, agent_id, opted_in)
}
TaskResult::CodingDataSharingFailed {
agent_id,
error,
rollback_to_opted_in,
} => handle_coding_data_sharing_failed(app, agent_id, error, rollback_to_opted_in),
TaskResult::RenameSessionComplete { agent_id, title } => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
let safe = crate::views::session_title::sanitize_display_text(&title);
@@ -873,32 +809,8 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
app.auth_clipboard_copied = false;
vec![]
}
TaskResult::PaywallCheckTick => {
let timed_out = app
.paywall_check_started
.is_some_and(|t| t.elapsed() >= PAYWALL_AUTO_CHECK_TIMEOUT);
if !app.has_access() && !timed_out {
vec![
Effect::CheckSubscription { verify: None },
Effect::SchedulePaywallCheck,
]
} else {
vec![]
}
}
TaskResult::CheckSubscriptionComplete { verify, meta } => {
handle_check_subscription_complete(app, verify, meta)
}
TaskResult::GateVerifyTimeout { generation } => handle_gate_verify_timeout(app, generation),
TaskResult::CreditLimitRecheckComplete { agent_id, meta } => {
handle_credit_limit_recheck_complete(app, agent_id, meta)
}
TaskResult::LogoutComplete => {
app.auth_state = AuthState::Pending { error: None };
app.access_gate_shown_logged = false;
app.gate = None;
app.pending_gate_verification = None;
app.last_subscription_check_at = None;
app.login_method_id = None;
ensure_login_method(app);
app.auth_clipboard_copied = false;
File diff suppressed because it is too large Load Diff
@@ -1270,37 +1270,6 @@ fn dashboard_slash_model_stages_pending_model() {
);
}
/// A tier-restricted command typed into the dashboard dispatch input must
/// upsell via the feedback toast — not execute, and (crucially) not fall
/// through the unknown-command path, which would spawn a session whose
/// first prompt is the raw slash text.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_slash_restricted_command_upsells_via_toast() {
let mut app = test_app();
app.tier_restricted_commands = vec!["imagine".to_string()];
open_dashboard(&mut app);
let effects = dispatch_dashboard_dispatch_slash(&mut app, "/imagine a sunset".into());
assert!(effects.is_empty(), "restricted command must not dispatch");
assert!(
app.agents.is_empty(),
"no session may be spawned for the raw slash text"
);
let toast = app
.dashboard
.as_ref()
.unwrap()
.error_toast
.as_deref()
.expect("restricted command must set the upsell toast");
assert!(
toast.contains("/imagine") && toast.contains("SuperGrok"),
"toast must carry the upsell: {toast}"
);
}
/// A slash command that fails (`CommandResult::Error`) surfaces on
/// the dashboard with the `✗` error prefix — command error strings
/// carry no glyph of their own, and the feedback badge paints the
@@ -15,10 +15,6 @@ mod status;
mod task_result;
mod transcript;
mod turn;
use super::billing::{
CreditLimitUpsellMode, credit_limit_upsell_mode, is_max_tier, open_credit_limit_upsell,
open_free_usage_upsell,
};
use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mut};
use super::dashboard::{
apply_pending_dispatch_config, dispatch_dashboard_attach, dispatch_dashboard_begin_rename,
@@ -133,24 +129,9 @@ fn test_app() -> AppView {
deferred_startup: Default::default(),
auth_use_oauth: false,
auth_clipboard_copied: false,
team_id: None,
team_name: None,
is_zdr: false,
team_role: None,
coding_data_retention_opt_out: false,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
zdr_access_enabled: false,
usage_billing_redirect_url: None,
access_gate_shown_logged: false,
gate: None,
subscription_tier: None,
paywall_check_started: None,
last_subscription_check_at: None,
subscription_watch_interval_secs: None,
pending_gate_verification: None,
gate_verify_gen: 0,
bundle_state: crate::app::bundle::BundleState::default(),
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
fps_hud: crate::views::fps_hud::FpsHud::new(),
@@ -172,8 +153,6 @@ fn test_app() -> AppView {
welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None,
welcome_refresh_rect: None,
welcome_gate_url_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
@@ -213,13 +192,8 @@ fn test_app() -> AppView {
minimal_state: crate::minimal_api::MinimalState::default(),
reconnect_pending: false,
show_resolved_model: true,
sharing_enabled: false,
usage_visible: true,
tier_restricted_commands: Vec::new(),
leader_mode: true,
credit_balance: None,
auto_topup: None,
billing_poll_wanted: false,
leader_roster: Vec::new(),
dashboard_local_sessions: Vec::new(),
dashboard_sessions_loading: false,
@@ -262,8 +236,6 @@ fn make_test_agent_session(app: &AppView, id: AgentId, sid: &str) -> AgentSessio
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
@@ -446,8 +418,6 @@ fn insert_placeholder_agent(app: &mut AppView, id: AgentId) {
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
@@ -591,8 +561,6 @@ fn two_agent_app_with_bg_task() -> AppView {
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
@@ -895,17 +863,3 @@ fn reset_mouse_capture_enabled(on: bool) {
fn mouse_capture_is_enabled() -> bool {
crate::app::MOUSE_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::Acquire)
}
/// Build a minimal `CreditBalance` for billing dispatch tests.
fn test_bal(usage_pct: f64) -> crate::views::credit_bar::CreditBalance {
crate::views::credit_bar::CreditBalance {
usage_pct,
effective_usage_pct: usage_pct,
period_end_display: None,
pay_as_you_go: false,
on_demand_cap_cents: None,
on_demand_used_cents: None,
prepaid_balance_cents: None,
period_type: None,
is_unified_billing_user: None,
}
}
@@ -733,13 +733,8 @@ fn turn_end_drains_next_queued_prompt() {
&mut app,
);
// No re-send (the prompt was already sent at enqueue time): only the
// billing refresh effect.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// No re-send (the prompt was already sent at enqueue time).
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_turn_running());
// current_prompt_id was handed off to the second prompt for correlation.
assert_eq!(
@@ -772,12 +767,8 @@ fn turn_end_with_empty_queue_stays_idle() {
&mut app,
);
// Silent billing refresh after turn completion.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// Turn completion produces no follow-up effects.
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
// Session event "Worked for" added.
assert_eq!(app.agents[&id].scrollback.len(), 1);
@@ -806,31 +797,19 @@ fn multiple_queued_prompts_drain_one_per_turn() {
})
};
// Turn end → drain "b" + FetchBilling.
// Turn end → drain "b".
let effects = dispatch(end_turn(), &mut app);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "b"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
));
assert_eq!(app.agents[&id].session.queue_len(), 1);
// Turn end → drain "c" + FetchBilling.
// Turn end → drain "c".
let effects = dispatch(end_turn(), &mut app);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "c"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
));
assert_eq!(app.agents[&id].session.queue_len(), 0);
// Turn end → FetchBilling only.
// Turn end with an empty queue → nothing to drain.
let effects = dispatch(end_turn(), &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
}
@@ -852,12 +831,8 @@ fn prompt_response_resets_turn_state() {
}),
&mut app,
);
// Silent billing refresh after turn completion.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// Turn completion produces no follow-up effects.
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
assert!(app.agents[&id].turn_started_at.is_none());
// mark_turn_finished must stamp the activity anchor used by the
@@ -892,7 +867,7 @@ fn turn_end_fetches_prompt_suggestion_when_enabled() {
&mut app,
);
assert_eq!(effects.len(), 2, "suggestion fetch + billing: {effects:?}");
assert_eq!(effects.len(), 1, "suggestion fetch: {effects:?}");
let Effect::FetchPromptSuggestion {
agent_id,
generation,
@@ -1249,12 +1224,8 @@ fn turn_complete_notification_suppressed_when_queue_non_empty() {
}),
&mut app,
);
// No re-send; only billing refresh. The second prompt is adopted.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// No re-send. The second prompt is adopted.
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_turn_running());
assert!(
app.deferred_notification.is_none(),
@@ -1536,12 +1507,8 @@ fn prompt_response_resets_cancelling_to_idle() {
}),
&mut app,
);
// Silent billing refresh after turn completion.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// Turn completion produces no follow-up effects.
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
// Cancellation produces a "Turn cancelled" session event.
assert_eq!(app.agents[&id].scrollback.len(), 1);
@@ -1578,12 +1545,8 @@ fn cancel_with_queued_prompt_drains_on_completion() {
&mut app,
);
assert_eq!(effects.len(), 2);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
));
assert!(app.agents[&id].session.state.is_turn_running());
assert_eq!(app.agents[&id].session.queue_len(), 0);
}
@@ -1605,12 +1568,8 @@ fn cancel_with_empty_queue_stays_idle() {
}),
&mut app,
);
// Silent billing refresh after turn completion.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// Turn completion produces no follow-up effects.
assert!(effects.is_empty(), "no effects expected: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
}
@@ -1660,12 +1619,8 @@ fn cancel_with_multiple_queued_prompts_drains_only_front_prompt() {
&mut app,
);
assert_eq!(effects.len(), 2);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued-1"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
));
assert!(app.agents[&id].session.state.is_turn_running());
assert_eq!(app.agents[&id].session.queue_len(), 1);
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "queued-2");
@@ -1706,12 +1661,8 @@ fn cancel_drain_is_blocked_when_editing_front_prompt() {
&mut app,
);
// Drain blocked but billing refresh still happens.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::FetchBilling { silent: true, .. }
));
// Drain blocked — no effects.
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
assert!(app.agents[&id].session.state.is_idle());
assert_eq!(app.agents[&id].session.queue_len(), 2);
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "queued-1");
@@ -220,7 +220,6 @@ fn native_empty_waits_for_foreign_and_foreign_only_rows_survive() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 1,
query: None,
}),
@@ -267,7 +266,6 @@ fn foreign_empty_then_native_empty_finishes_once_without_resurrecting() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 3,
query: None,
}),
@@ -331,7 +329,6 @@ fn modal_empty_notice_waits_until_both_lanes_are_empty() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 9,
query: None,
}),
@@ -435,7 +432,6 @@ fn modal_selection_survives_native_and_foreign_completion_races() {
at(make_picker_entry("a", "/repo"), 20),
at(make_picker_entry("b", "/repo"), 10),
],
partial: None,
seq: 2,
query: None,
}),
@@ -723,27 +723,14 @@ fn dispatch_fork_stashes_directive_in_pending_first_prompt() {
}
#[test]
fn dispatch_fork_inherits_appearance_sharing_and_plugin_visibility() {
fn dispatch_fork_inherits_appearance_and_plugin_visibility() {
let mut app = fork_test_app();
// Tweak app-level state so we can verify the sweep applied it.
app.appearance.prompt.compact = true;
app.sharing_enabled = false;
app.usage_visible = false;
app.appearance.disable_plugins = true;
// Cached billing state must be inherited so the credits warning is
// correct from the first frame (not just after a billing fetch).
app.credit_balance = Some(crate::views::credit_bar::CreditBalance {
prepaid_balance_cents: Some(1500),
..test_bal(50.0)
});
app.auto_topup = Some(crate::views::credit_bar::AutoTopupInfo {
enabled: true,
topup_amount_cents: Some(2000),
max_amount_cents: None,
});
dispatch(Action::Fork(fork_args(Some(false), None)), &mut app);
let new_agent = app.agents.get(&AgentId(1)).unwrap();
assert!(!new_agent.sharing_enabled);
assert!(
new_agent
.prompt
@@ -752,14 +739,6 @@ fn dispatch_fork_inherits_appearance_sharing_and_plugin_visibility() {
.get("usage")
.is_none()
);
assert_eq!(
new_agent
.credit_balance
.as_ref()
.and_then(|b| b.prepaid_balance_cents),
Some(1500)
);
assert!(new_agent.auto_topup.as_ref().is_some_and(|at| at.enabled));
}
#[test]
@@ -50,7 +50,7 @@ fn session_created_sets_session_id() {
}),
&mut app,
);
assert_eq!(effects.len(), 5);
assert_eq!(effects.len(), 4);
assert!(
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if
session_id == "new-session-123")
@@ -60,11 +60,7 @@ fn session_created_sets_session_id() {
&effects[2],
Effect::RefreshAvailableCommands { .. }
));
assert!(matches!(
&effects[3],
Effect::FetchBilling { silent: true, .. }
));
assert!(matches!(&effects[4], Effect::RegisterActiveSession { .. }));
assert!(matches!(&effects[3], Effect::RegisterActiveSession { .. }));
assert_eq!(
app.agents[&id]
.session
@@ -198,11 +194,6 @@ fn worktree_session_created_sets_session_and_cwd() {
.iter()
.any(|e| matches!(e, Effect::FetchSessionAgentName { .. }))
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::FetchBilling { silent: true, .. }))
);
assert!(
effects
.iter()
@@ -1836,7 +1836,6 @@ fn stale_session_list_responses_are_dropped() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-stale-1")],
partial: None,
seq: 1,
query: None,
}),
@@ -1861,7 +1860,6 @@ fn stale_session_list_responses_are_dropped() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-fresh-2")],
partial: None,
seq: 2,
query: Some("abcd".into()),
}),
@@ -1906,7 +1904,6 @@ fn modal_search_response_lands_and_stale_is_dropped() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-hit-1")],
partial: None,
seq: 1,
query: Some("hit".into()),
}),
@@ -1944,7 +1941,6 @@ fn modal_search_response_lands_and_stale_is_dropped() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-stale-m")],
partial: None,
seq: 1,
query: Some("hit".into()),
}),
@@ -1999,7 +1995,6 @@ fn modal_close_drops_in_flight_search_response() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-late-1")],
partial: None,
seq,
query: Some("hit".into()),
}),
@@ -2045,7 +2040,6 @@ fn modal_pick_drops_in_flight_search_response() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-late-p")],
partial: None,
seq,
query: Some("hit".into()),
}),
@@ -2089,7 +2083,6 @@ fn welcome_esc_drops_in_flight_fetch_response() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-late-w")],
partial: None,
seq,
query: None,
}),
@@ -2119,7 +2112,6 @@ fn build_mode_modal_close_does_not_invalidate_plain_fetch() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("build-late-1", "/tmp/repo")],
partial: None,
seq,
query: None,
}),
@@ -2142,7 +2134,6 @@ fn zero_hit_search_shows_empty_list_without_toast() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 1,
query: Some("zzz".into()),
}),
@@ -2163,7 +2154,6 @@ fn zero_hit_search_shows_empty_list_without_toast() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 2,
query: None,
}),
@@ -2347,7 +2337,6 @@ fn build_mode_list_response_preserves_deep_search_spinner() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("local-1", "/r")],
partial: None,
seq: app.session_picker_list_seq,
query: None,
}),
@@ -2400,7 +2389,6 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("build-first", "/r")],
partial: None,
seq: 0,
query: None,
}),
@@ -2416,7 +2404,6 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() {
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("build-second", "/r")],
partial: None,
seq: 0,
query: None,
}),
@@ -1209,9 +1209,6 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti
"max_thoughts_width" => {
let _ = dispatch(Action::SetMaxThoughtsWidth(200), app);
}
"coding_data_sharing" => {
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, app);
}
"plan_mode" => {
let _ = dispatch(
Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
@@ -1388,8 +1385,6 @@ fn set_simple_mode_propagates_to_every_agent() {
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
@@ -1,4 +1,4 @@
//! Tests for session status, sharing, privacy, and coding-data-sharing dispatchers.
//! Tests for session status and sharing dispatchers.
use super::*;
@@ -60,659 +60,6 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
assert_eq!(q.last().map(|e| e.text.as_str()), Some("c"));
}
#[test]
fn show_privacy_info_zdr() {
let mut app = test_app_with_agent();
app.is_zdr = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(text.contains("Zero Data Retention"));
}
/// `/privacy` info-print uses the desktop-aligned "privacy mode" /
/// "share data" labels from the user's intentional rewrite.
#[test]
fn show_privacy_info_opted_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: privacy mode"),
"info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-in"));
}
#[test]
fn show_privacy_info_opted_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: share data"),
"info-print must use 'Privacy: share data' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-out"));
}
/// The info-print uses desktop-aligned labels ("privacy mode" /
/// "share data"). This test pins those labels to catch accidental
/// regressions to the registry's "Opt in" / "Opt out" display
/// strings.
#[test]
fn show_privacy_info_does_not_use_old_desktop_labels() {
// opted-out → "Privacy: privacy mode"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("privacy mode"),
"[opted-out] info-print must contain 'privacy mode': {text:?}",
);
// opted-in → "Privacy: share data"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("share data"),
"[opted-in] info-print must contain 'share data': {text:?}",
);
}
// ── coding_data_sharing dispatch tests ───
//
// The dispatcher uses **optimistic + rollback + toast**, matching the
// `set_yolo_mode` pattern. These tests pin the contract:
// - Guards (ZDR, non-admin team) toast and short-circuit.
// - Idempotent dispatch toasts but emits no Effect.
// - Optimistic mutation flips `app.coding_data_retention_opt_out`
// BEFORE the Effect is emitted.
// - `Effect::SetCodingDataSharing` carries
// `rollback_to_opted_in = previous_value`.
// - `TaskResult::CodingDataSharingFailed` reverts the optimistic
// mutation; `TaskResult::CodingDataSharingUpdated` re-anchors
// to the server-confirmed value.
/// Idempotent re-dispatch when already opted-in toasts but emits
/// no Effect (avoids a wasted ACP round-trip).
///
/// Toast uses the **display name** ("Opt in", not the
/// snake-case canonical "opt-in") AND the **destructive `⚠`
/// glyph** on the opt-in direction (privacy-degrading).
#[test]
fn set_coding_data_sharing_idempotent_opt_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"toast must show display name 'Opt in' (PR 9 R1, General-3 Issue 6): {toast}",
);
assert!(
!toast.contains("opt-in"),
"toast must NOT use snake-case canonical 'opt-in' — display name only: {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"idempotent opt-in toast uses ⚠ destructive-warning glyph (PR 9 R1, \
General-3 Issue 5): {toast}",
);
// State unchanged.
assert!(
!app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
}
/// Idempotent re-dispatch when already opted-out toasts but emits
/// no Effect.
///
/// Opt-out direction uses the **uniform `✓` glyph**
/// (restoring the safe default) and the display name "Opt out".
#[test]
fn set_coding_data_sharing_idempotent_opt_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"toast must show display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"idempotent opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out is the safe direction — must NOT use ⚠: {toast}",
);
// State unchanged.
assert!(
app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
}
/// ZDR teams are blocked from toggling. The blocked path
/// toasts (not scrollback) and short-circuits with no Effect.
#[test]
fn set_coding_data_sharing_blocked_by_zdr() {
let mut app = test_app_with_agent();
app.is_zdr = true;
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(effects.is_empty(), "ZDR block must NOT emit Effect");
let toast = read_toast(&app);
assert!(
toast.contains("Zero Data Retention"),
"ZDR toast must surface the policy: {toast}",
);
assert!(
toast.contains('\u{2717}'),
"blocked toast uses ✗ glyph: {toast}"
);
// State unchanged — the user was blocked, the optimistic
// mutation never happened.
assert!(
!app.coding_data_retention_opt_out,
"ZDR block must not mutate state",
);
}
/// ZDR block fires even when the toggle would be a no-op
/// (defense-in-depth: don't quietly accept a same-value toggle
/// from a user the policy says shouldn't be touching this).
#[test]
fn set_coding_data_sharing_blocked_by_zdr_even_if_idempotent() {
let mut app = test_app_with_agent();
app.is_zdr = true;
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert!(effects.is_empty());
assert!(read_toast(&app).contains("Zero Data Retention"));
}
/// Non-admin team members are blocked from toggling (matches
/// desktop). The blocked path toasts and short-circuits.
#[test]
fn set_coding_data_sharing_blocked_non_admin() {
let mut app = test_app_with_agent();
app.team_name = Some("Acme".into());
app.team_role = Some("Member".into());
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(effects.is_empty());
let toast = read_toast(&app);
assert!(
toast.contains("team admin"),
"non-admin toast must mention team admin: {toast}",
);
}
/// Admin team members CAN toggle. The admin-allowed path produces
/// an Effect carrying the rollback value.
#[test]
fn set_coding_data_sharing_allowed_for_admin() {
let mut app = test_app_with_agent();
app.team_name = Some("Acme".into());
app.team_role = Some("Admin".into());
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::SetCodingDataSharing {
opted_in,
rollback_to_opted_in,
..
} => {
assert!(!*opted_in, "Effect must carry opted_in=false");
assert!(
*rollback_to_opted_in,
"rollback_to_opted_in must capture pre-toggle opt-in=true",
);
}
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
}
// Optimistic mutation already applied.
assert!(
app.coding_data_retention_opt_out,
"admin-allowed dispatch must optimistically flip state",
);
}
/// Non-idempotent dispatch emits one Effect AND mutates state
/// optimistically AND toasts.
#[test]
fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert_eq!(effects.len(), 1, "non-idempotent dispatch emits one Effect");
match &effects[0] {
Effect::SetCodingDataSharing {
agent_id,
opted_in,
rollback_to_opted_in,
} => {
assert_eq!(*agent_id, AgentId(0));
assert!(!*opted_in);
assert!(
*rollback_to_opted_in,
"rollback_to_opted_in must be pre-toggle value (true == opted-in)",
);
}
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
}
// Optimistic mutation applied.
assert!(
app.coding_data_retention_opt_out,
"dispatch must optimistically mutate state",
);
// Toast on every dispatch (SHELL setter contract).
assert!(app.agents[&AgentId(0)].toast.is_some());
}
/// `TaskResult::CodingDataSharingUpdated` re-anchors state to the
/// server-confirmed value (defense-in-depth) and re-toasts.
#[test]
fn coding_data_sharing_updated_re_anchors_state_and_re_toasts() {
let mut app = test_app_with_agent();
// Simulate post-optimistic state: opted-out.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// Server confirms opt-out (same as optimistic).
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: false,
}),
&mut app,
);
assert!(effects.is_empty(), "TaskResult arm must NOT emit Effect");
// State re-anchored (was already true, stays true).
assert!(app.coding_data_retention_opt_out);
// Re-toast on confirmation uses display name + ✓.
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"confirmation toast must use display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"opt-out confirmation toast uses ✓: {toast}",
);
}
/// `TaskResult::CodingDataSharingUpdated` corrects the in-memory
/// state if the server reshapes the boolean (e.g. policy
/// override). Pins the defense-in-depth re-anchor contract.
#[test]
fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
let mut app = test_app_with_agent();
// Optimistic mutation said "opt-out" — but the server
// overrides to "opt-in" (e.g. policy that prevents opt-out).
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: true, // server says opted-in
}),
&mut app,
);
assert!(effects.is_empty());
// State corrected to match server.
assert!(
!app.coding_data_retention_opt_out,
"server-confirmed opt-in must overwrite optimistic opt-out",
);
// Server-correction toast uses the destructive ⚠
// pattern for the opt-in direction (the privacy-degrading
// override deserves the warning glyph even if the SERVER, not
// the user, made the call).
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"post-correction toast uses display name 'Opt in': {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"opt-in direction always uses ⚠ glyph, even on server-correction path: {toast}",
);
}
/// `TaskResult::CodingDataSharingFailed` REVERTS the optimistic
/// mutation and surfaces a failure toast. Pins the rollback
/// contract.
///
/// Failure toast uses the standardised "coding data sharing"
/// wording.
#[test]
fn coding_data_sharing_failed_rolls_back_and_toasts_error() {
let mut app = test_app_with_agent();
// Simulate post-optimistic state: user picked opt-out, state
// was flipped, then the ACP call failed. The pre-toggle value
// was opt-in (true), so `rollback_to_opted_in = true`.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "server error".into(),
rollback_to_opted_in: true,
}),
&mut app,
);
assert!(effects.is_empty(), "rollback path must NOT emit Effect");
// State reverted to pre-toggle (opted-in).
assert!(
!app.coding_data_retention_opt_out,
"rollback must revert optimistic mutation",
);
// Failure toast surfaces the error using full label.
let toast = read_toast(&app);
assert!(
toast.contains("coding data sharing"),
"PR 9 R1: failure toast wording standardised to include 'coding data sharing' \
(G2 Issue 2): {toast}",
);
assert!(toast.contains("server error"), "error in toast: {toast}");
assert!(toast.contains('\u{2717}'), "failure toast uses ✗: {toast}");
}
/// `TaskResult::CodingDataSharingFailed` reverts in the OTHER
/// direction too (the pre-toggle state could have been either).
#[test]
fn coding_data_sharing_failed_rolls_back_to_opt_out() {
let mut app = test_app_with_agent();
// Post-optimistic: opted-in (user picked opt-in, server
// failed, pre-toggle was opt-out).
app.coding_data_retention_opt_out = false;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "network timeout".into(),
rollback_to_opted_in: false,
}),
&mut app,
);
assert!(effects.is_empty());
// Reverted to pre-toggle opt-out.
assert!(
app.coding_data_retention_opt_out,
"rollback to opt-out must set state=true",
);
}
/// Optimistic mutation refreshes any open settings modal.
/// Without this refresh, the modal indicator would stay at the
/// pre-toggle value until manual re-render.
#[test]
fn set_coding_data_sharing_refreshes_open_modal_snapshot() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
// Open a settings modal (capture initial snapshot).
let _ = dispatch(Action::OpenSettings, &mut app);
// Verify snapshot reads opted-in.
let agent_id = AgentId(0);
{
let state = match &app.agents[&agent_id].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("expected Settings modal open after OpenSettings dispatch"),
};
assert!(
!state.pager_snapshot.coding_data_sharing_opt_out,
"initial snapshot must read opt_out=false (opted-in)",
);
}
// Dispatch the toggle.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
// Snapshot now reflects the optimistic mutation.
let state = match &app.agents[&agent_id].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("Settings modal must still be open after SetCodingDataSharing dispatch"),
};
assert!(
state.pager_snapshot.coding_data_sharing_opt_out,
"snapshot must refresh to reflect opt_out=true (opted-out) after dispatch",
);
}
/// Rollback also refreshes the modal — the user sees the
/// reverted value, not the stale optimistic one.
#[test]
fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::OpenSettings, &mut app);
// Optimistic flip.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
// ACP failure.
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "x".into(),
rollback_to_opted_in: true,
}),
&mut app,
);
let state = match &app.agents[&AgentId(0)].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("Settings modal must still be open after rollback TaskResult"),
};
assert!(
!state.pager_snapshot.coding_data_sharing_opt_out,
"rollback must refresh snapshot back to opt_out=false (opted-in)",
);
}
// ── coding_data_sharing toast tests ─────────────
/// The opt-in transition
/// uses the **`⚠` destructive-warning glyph** + spelled-out
/// consequence text — mirroring `yolo_toast`'s
/// "Always-approve ON: all tool actions auto-run" pattern. The
/// consequence text is verbatim-pinned because the toast is the
/// only post-commit feedback for a privacy-degrading transition;
/// a future PR that softens the wording silently degrades the
/// safety affordance.
#[test]
fn set_coding_data_sharing_opt_in_renders_destructive_warning_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert_eq!(effects.len(), 1, "non-idempotent opt-in must emit Effect");
let toast = read_toast(&app);
assert!(
toast.contains('\u{26A0}'),
"opt-in toast MUST use ⚠ glyph (PR 9 R1, General-3 Issue 5 — \
privacy-degrading transition deserves destructive-warning glyph): {toast}",
);
assert!(
!toast.contains('\u{2713}'),
"opt-in toast MUST NOT use the uniform ✓ glyph — that's the \
safe-default toast for opt-out: {toast}",
);
assert!(
toast.contains("Opt in"),
"destructive toast still uses display name 'Opt in': {toast}",
);
// Consequence text pinned: a future PR softening this loses
// the safety affordance.
assert!(
toast.contains("code samples"),
"destructive toast must spell out the consequence \
(mention 'code samples'): {toast}",
);
assert!(
toast.contains("training"),
"destructive toast must spell out the consequence \
(mention 'training'): {toast}",
);
}
/// The opt-out transition uses the
/// uniform `✓` glyph (safe default), NOT the destructive `⚠`.
/// Mirrors `yolo_toast(false)` precedent — restoring the safe
/// default doesn't warrant the heavier visual.
#[test]
fn set_coding_data_sharing_opt_out_renders_safe_default_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let toast = read_toast(&app);
assert!(
toast.contains('\u{2713}'),
"opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out toast MUST NOT use ⚠ — that's reserved for the privacy-degrading \
direction (PR 9 R1): {toast}",
);
assert!(toast.contains("Opt out"));
}
/// The toast renders
/// the registered `EnumChoice.display` ("Opt in" / "Opt out"),
/// NOT the persisted canonical ("opt-in" / "opt-out"). Mirrors
/// the `set_theme_toast_format_uses_display_name` contract.
/// The display strings here are pinned by the
/// `coding_data_sharing_choices_use_canonical_strings` e2e test
/// (registry side) AND
/// `pr9_coding_data_sharing_choices_use_canonical_strings` (which
/// also pins the display labels via the same EnumChoice
/// entries).
#[test]
fn coding_data_sharing_toast_format_uses_display_name() {
let mut app = test_app_with_agent();
// Opt-in direction.
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
let opt_in_toast = read_toast(&app);
assert!(
opt_in_toast.contains("Opt in"),
"opt-in toast uses display 'Opt in', not canonical 'opt-in': {opt_in_toast}",
);
// Clear and test opt-out direction.
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let opt_out_toast = read_toast(&app);
assert!(
opt_out_toast.contains("Opt out"),
"opt-out toast uses display 'Opt out', not canonical 'opt-out': {opt_out_toast}",
);
}
/// The failure toast
/// substitutes a generic placeholder when the error string is
/// too long OR contains control characters / newlines. Pins the
/// scrub contract.
#[test]
fn coding_data_sharing_failed_scrubs_long_error_messages() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// ~500-char error simulating a stack trace / HTML 502 page.
let huge_error = "a".repeat(500);
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: huge_error.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
!toast.contains(&huge_error),
"long error MUST be scrubbed from the toast: {} chars",
toast.len(),
);
assert!(
toast.contains("see logs"),
"scrubbed toast must point at the log for full details: {toast}",
);
}
/// Control characters (CR/LF/NUL)
/// in the error trigger the scrub path even on short strings —
/// preserves the toast's single-line layout.
#[test]
fn coding_data_sharing_failed_scrubs_control_chars_in_error() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// Short message with embedded newlines.
let multiline = "line1\nline2\nline3".to_string();
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: multiline.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
!toast.contains('\n'),
"newlines MUST be scrubbed from the toast (would break single-line layout): \
{toast:?}",
);
assert!(
toast.contains("see logs"),
"control-char-scrubbed toast points at logs: {toast}",
);
}
/// The scrub path preserves short,
/// sanitised error messages verbatim — the typical happy-path
/// shell-side error string stays unscrubbed.
#[test]
fn coding_data_sharing_failed_preserves_short_clean_error_message() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let short_clean = "network timeout".to_string();
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: short_clean.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
toast.contains(&short_clean),
"short clean error must appear verbatim in the toast: {toast}",
);
assert!(
!toast.contains("see logs"),
"short clean error must NOT trigger the scrub fallback: {toast}",
);
}
/// Direct unit test of the `scrub_error_for_toast` helper —
/// pins the threshold and the fallback string against drift.
#[test]
@@ -766,31 +113,6 @@ fn scrub_error_for_toast_unit() {
);
}
/// The no-agent path
/// returns empty cleanly — no toast (the show_toast call would
/// no-op anyway), no panic, no Effect emitted. A "✗ No active
/// session" toast would be dead UX (no agent = no toast surface
/// to render on), so this path emits a tracing::warn! instead.
#[test]
fn set_coding_data_sharing_no_agents_returns_empty_without_panic() {
let mut app = test_app_with_agent();
// Remove every agent so the dispatcher hits the no-agent path.
app.agents.clear();
// Force the view off Agent so the dispatcher falls through to
// app.agents.keys().next() which is now empty.
app.active_view = ActiveView::Welcome;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(
effects.is_empty(),
"no-agent path must return empty (no Effect to fire)",
);
// State unchanged (we never reach the optimistic mutation).
assert!(
!app.coding_data_retention_opt_out,
"no-agent path must NOT mutate state",
);
}
#[test]
fn dispatch_rename_session_updates_display_name_locally() {
let mut app = test_app_with_agent();
@@ -898,27 +220,6 @@ fn show_usage_on_welcome_screen_is_noop() {
);
}
#[test]
fn show_usage_with_redirect_url_shows_link_and_skips_fetch() {
let mut app = test_app_with_agent();
app.usage_billing_redirect_url = Some("https://billing.example.com/me".to_string());
let before = agent_scrollback_len(&app);
let effects = dispatch(Action::ShowUsage, &mut app);
assert!(
effects.is_empty(),
"with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}"
);
assert_eq!(
agent_scrollback_len(&app),
before + 1,
"redirect path should push one system message with the billing link"
);
assert!(
last_system_text(&app, AgentId(0)).contains("https://billing.example.com/me"),
"redirect message should use the remote settings-provided URL"
);
}
// ── Minimal update-notice tests ──────────────────────────────────────
#[test]
@@ -1494,391 +1494,6 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
);
}
// ── GateRefreshed subscription flow ─────────────────────────────
/// Regression: when the 30s gate poll detects the subscription gate has
/// been lifted, it must emit `CheckSubscription` so the shell refreshes
/// the JWT. Without this the auth token still lacks the subscription
/// claim and all API calls return 403.
#[test]
fn gate_refreshed_emits_check_subscription_on_gate_lift() {
let mut app = test_app();
// User starts gated (no subscription).
app.gate = Some(kigi_shell::auth::GateInfo {
message: "SuperGrok subscription required".into(),
url: Some("https://grok.com/supergrok".into()),
label: Some("Subscribe".into()),
});
assert!(!app.has_access());
// Server-side settings now show no gate (user purchased subscription).
let settings = kigi_shell::util::config::RemoteSettings::default();
let effects = dispatch_task_result(
TaskResult::GateRefreshed {
settings: Some(settings),
},
&mut app,
);
// Gate must be lifted.
assert!(app.has_access(), "gate should be lifted");
assert!(app.welcome_prompt_focused, "prompt should be focused");
// Must emit CheckSubscription to trigger shell-side JWT refresh.
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CheckSubscription { verify: None })),
"must emit CheckSubscription to refresh JWT; got: {effects:?}"
);
}
/// When the gate poll returns settings that still have a gate, no
/// effects should be emitted and the user stays blocked.
#[test]
fn gate_refreshed_no_effect_when_still_gated() {
let mut app = test_app();
app.gate = Some(kigi_shell::auth::GateInfo {
message: "Subscribe".into(),
url: None,
label: None,
});
let settings = kigi_shell::util::config::RemoteSettings {
gate_message: Some("Subscribe".into()),
..Default::default()
};
let effects = dispatch_task_result(
TaskResult::GateRefreshed {
settings: Some(settings),
},
&mut app,
);
assert!(!app.has_access(), "gate should remain");
assert!(effects.is_empty(), "no effects when still gated");
}
/// When the user was never gated, GateRefreshed is a no-op.
#[test]
fn gate_refreshed_no_effect_when_already_unblocked() {
let mut app = test_app();
assert!(app.has_access()); // no gate
let settings = kigi_shell::util::config::RemoteSettings::default();
let effects = dispatch_task_result(
TaskResult::GateRefreshed {
settings: Some(settings),
},
&mut app,
);
assert!(effects.is_empty(), "no effects when already unblocked");
}
/// A gate newly imposed by the 30s settings poll (possibly stale) must be
/// deferred for live verification instead of painting the paywall directly:
/// the gate is held out of `app.gate` and a `CheckSubscription` +
/// verify-timeout pair is emitted.
#[test]
fn gate_refreshed_newly_blocked_defers_gate_for_verification() {
let mut app = test_app();
assert!(app.has_access()); // ungated
let settings = kigi_shell::util::config::RemoteSettings {
gate_message: Some("Subscribe".into()),
..Default::default()
};
let effects = dispatch_task_result(
TaskResult::GateRefreshed {
settings: Some(settings),
},
&mut app,
);
assert!(
app.has_access(),
"deferred gate must not show as paywall before verification"
);
assert!(app.pending_gate_verification.is_some());
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CheckSubscription { verify: Some(_) })),
"must live-check before showing the paywall; got: {effects:?}"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ScheduleGateVerifyTimeout { .. })),
"must arm the verification timeout; got: {effects:?}"
);
}
// ── Stale-gate verification resolution ──────────────────────────
fn test_gate() -> kigi_shell::auth::GateInfo {
kigi_shell::auth::GateInfo {
message: "Subscribe".into(),
url: None,
label: None,
}
}
/// The live check confirmed access (meta without a gate): the deferred
/// stale gate is dropped and the paywall never shows.
#[test]
fn verify_check_with_meta_resolves_pending_gate() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
assert!(app.has_access());
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: Some(app.gate_verify_gen),
meta: Some(meta),
},
&mut app,
);
assert!(app.has_access(), "live check says subscribed — no paywall");
assert!(app.pending_gate_verification.is_none());
}
/// The verification's own check failed (meta None) while its stale gate
/// was deferred: err on blocking — the deferred gate is promoted.
#[test]
fn verify_check_failure_promotes_pending_gate() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let effects = dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: Some(app.gate_verify_gen),
meta: None,
},
&mut app,
);
assert!(!app.has_access(), "check failed — deferred gate must show");
assert!(app.pending_gate_verification.is_none());
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SchedulePaywallCheck)),
"freshly shown gate must arm the 5s auto-lift chain; got: {effects:?}"
);
}
/// A failed GENERIC check (watch / focus / paywall chain — no generation)
/// must never promote a deferred gate: only the deferral's own
/// generation-scoped check or timeout may (a superseded or unrelated check
/// failing is not evidence about the current verification).
#[test]
fn check_subscription_complete_failure_leaves_pending_gate_untouched() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let effects = dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: None,
},
&mut app,
);
assert!(effects.is_empty());
assert!(
app.has_access(),
"generic check failure must not promote the deferred gate"
);
assert!(
app.pending_gate_verification.is_some(),
"verification must stay in flight"
);
}
/// A failed verification check from a SUPERSEDED deferral (older
/// generation) must not promote the newer pending gate.
#[test]
fn verify_check_stale_generation_failure_is_ignored() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let stale_gen = app.gate_verify_gen;
// Second deferral supersedes the first (its check is in flight).
let _effs = app.impose_gate(test_gate());
let effects = dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: Some(stale_gen),
meta: None,
},
&mut app,
);
assert!(effects.is_empty());
assert!(
app.has_access(),
"superseded verification failure must not promote the newer gate"
);
assert!(app.pending_gate_verification.is_some());
}
/// A check failure with no deferred gate (the plain paywall-poller path)
/// must not invent a gate.
#[test]
fn check_subscription_complete_failure_without_pending_gate_is_noop() {
let mut app = test_app();
dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: None,
},
&mut app,
);
assert!(app.has_access());
}
/// The verification window expired before the live check resolved:
/// err on blocking — the deferred gate is promoted, and the freshly shown
/// paywall gets the 5s auto-lift chain.
#[test]
fn gate_verify_timeout_promotes_pending_gate() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
assert!(app.has_access());
let effects = dispatch_task_result(
TaskResult::GateVerifyTimeout {
generation: app.gate_verify_gen,
},
&mut app,
);
assert!(!app.has_access(), "timeout — deferred gate must show");
assert!(app.pending_gate_verification.is_none());
assert!(
app.paywall_check_started.is_some(),
"promoted gate must arm the paywall auto-check chain"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SchedulePaywallCheck)),
"promoted gate must schedule the 5s chain; got: {effects:?}"
);
}
/// The timeout fires after the check already resolved the gate: no-op.
#[test]
fn gate_verify_timeout_noop_when_already_resolved() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let generation = app.gate_verify_gen;
// Live check resolved first (access confirmed).
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: Some(meta),
},
&mut app,
);
dispatch_task_result(TaskResult::GateVerifyTimeout { generation }, &mut app);
assert!(
app.has_access(),
"stale timeout must not re-impose the gate"
);
}
/// A timeout from a SUPERSEDED verification (older generation) must not
/// promote a newer deferred gate whose own live check is still in flight.
#[test]
fn gate_verify_timeout_stale_generation_is_ignored() {
let mut app = test_app();
// First deferral resolves (access confirmed) ...
let _effs = app.impose_gate(test_gate());
let stale_gen = app.gate_verify_gen;
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap();
dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: Some(meta),
},
&mut app,
);
// ... then a SECOND gate is deferred (check in flight).
let _effs = app.impose_gate(test_gate());
assert!(app.has_access());
// The FIRST deferral's timer fires now — it must not promote the
// second deferral's pending gate.
let effects = dispatch_task_result(
TaskResult::GateVerifyTimeout {
generation: stale_gen,
},
&mut app,
);
assert!(effects.is_empty());
assert!(
app.has_access(),
"stale-generation timer must not promote the newer pending gate"
);
assert!(
app.pending_gate_verification.is_some(),
"the newer verification must stay in flight"
);
}
/// `GateRefreshed` with gate-free settings while a deferred gate awaits
/// verification must drop the pending copy — the fresh settings are newer
/// than the stale snapshot that produced it — and still run the lift
/// bookkeeping (`CheckSubscription` for the JWT refresh), since the pending
/// deferral means the user was conceptually blocked.
#[test]
fn gate_refreshed_without_gate_clears_pending_verification() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let generation = app.gate_verify_gen;
let settings = kigi_shell::util::config::RemoteSettings::default();
let effects = dispatch_task_result(
TaskResult::GateRefreshed {
settings: Some(settings),
},
&mut app,
);
assert!(app.pending_gate_verification.is_none());
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CheckSubscription { verify: None })),
"settings-confirmed lift of a pending gate must refresh the JWT; got: {effects:?}"
);
// The still-armed timer must find nothing to promote.
dispatch_task_result(TaskResult::GateVerifyTimeout { generation }, &mut app);
assert!(
app.has_access(),
"cleared pending gate must not resurface via the timer"
);
}
/// Logout clears any deferred gate and the check debounce.
#[test]
fn logout_clears_pending_gate_verification() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
dispatch_task_result(TaskResult::LogoutComplete, &mut app);
assert!(app.pending_gate_verification.is_none());
assert!(app.last_subscription_check_at.is_none());
}
/// `apply_setting_rollback` on a known key reverts the in-memory
/// cache without emitting any new effects.
#[test]
@@ -2002,38 +1617,16 @@ fn rollback_to_always_approve_blocked_by_policy_pin() {
assert!(!app.default_yolo);
}
// -- Degraded conversations lane (SessionListLoaded.partial) ----------
// -- SessionListLoaded ------------------------------------------------
/// A degraded conversations lane surfaces an actionable notice instead of
/// the misleading "No sessions found" toast.
/// Canary: an empty list surfaces the generic "no sessions" toast.
#[test]
fn session_list_partial_no_oauth_surfaces_login_hint() {
fn session_list_empty_shows_generic_toast() {
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![]);
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: Some(crate::app::effects::ConversationsPartial::NoOauth),
seq: 0,
query: None,
}),
&mut app,
);
assert!(
read_toast(&app).contains("/login"),
"no_oauth must point at /login"
);
}
/// Canary: an empty list without a degraded lane keeps the generic toast.
#[test]
fn session_list_empty_without_partial_keeps_generic_toast() {
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![]);
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
partial: None,
seq: 0,
query: None,
}),
@@ -2041,95 +1634,3 @@ fn session_list_empty_without_partial_keeps_generic_toast() {
);
assert!(read_toast(&app).contains("No sessions found"));
}
/// Non-empty degraded list under chat mode (welcome-fallback branch):
/// entries land AND the retry notice surfaces; Build mode stays silent.
#[test]
fn session_list_nonempty_partial_toasts_retry_in_chat_mode_only() {
let mut app = test_app_with_agent();
app.chat_mode = true;
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-part-1")],
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
seq: 0,
query: None,
}),
&mut app,
);
assert!(
app.session_picker_entries.is_some(),
"entries must still land on a degraded lane"
);
assert!(
read_toast(&app).contains("retry"),
"timeout must surface the retry notice"
);
// Build-mode canary: stays silent on a degraded lane.
let mut app = test_app_with_agent();
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("local-part-1", "/r")],
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
seq: 0,
query: None,
}),
&mut app,
);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"Build-mode non-empty degraded list stays silent"
);
}
/// Modal variant of the non-empty degraded-lane notice: same chat-mode-only
/// gating as the welcome-fallback branch.
#[test]
fn session_list_nonempty_partial_modal_toasts_in_chat_mode_only() {
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
app.chat_mode = true;
open_session_picker_with(&mut app, vec![]);
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_conversation_entry("conv-part-m1")],
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
seq: 0,
query: None,
}),
&mut app,
);
let agent = get_active_agent(&app).expect("active agent");
assert!(
matches!(
agent.active_modal.as_ref(),
Some(ActiveModal::SessionPicker {
entries: Some(list),
..
}) if list.len() == 1
),
"entries must land in the open modal on a degraded lane"
);
assert!(
read_toast(&app).contains("retry"),
"chat-mode modal must surface the retry notice"
);
// Build-mode canary: the open modal stays silent.
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![]);
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![make_picker_entry("local-part-m1", "/r")],
partial: Some(crate::app::effects::ConversationsPartial::Timeout),
seq: 0,
query: None,
}),
&mut app,
);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"Build-mode modal non-empty degraded list stays silent"
);
}
@@ -372,8 +372,7 @@ pub(super) fn dispatch_open_extensions_modal(
// Mutual exclusivity: close agents modal when opening extensions.
agent.agents_modal = None;
let mut modal = ExtensionsModalState::new(tab);
modal.session_team_id = app.team_id.clone();
let modal = ExtensionsModalState::new(tab);
agent.extensions_modal = Some(modal);
let Some(session_id) = agent.session.session_id.clone() else {