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:
1 parent
fe1f885bb3
commit
ea0ce9d15f
231 files changed
+4730
-26358
No files matched your search
@@ -41,7 +41,6 @@ pub mod new;
|
||||
pub mod personas;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod privacy;
|
||||
pub mod queue;
|
||||
pub mod recap;
|
||||
pub mod release_notes;
|
||||
@@ -53,7 +52,6 @@ pub mod screen_mode_switch;
|
||||
pub mod scroll_debug;
|
||||
pub mod session_info;
|
||||
pub mod settings_cmd;
|
||||
pub mod share;
|
||||
pub mod tasks;
|
||||
pub mod terminal_setup;
|
||||
pub mod theme;
|
||||
@@ -98,7 +96,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(plugin::HooksCommand),
|
||||
Arc::new(plugin::PluginsCommand),
|
||||
Arc::new(plugin::SkillsCommand),
|
||||
Arc::new(share::ShareCommand),
|
||||
Arc::new(session_info::SessionInfoCommand),
|
||||
Arc::new(rename::RenameCommand),
|
||||
Arc::new(dashboard::DashboardCommand),
|
||||
@@ -120,7 +117,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(timeline::TimelineCommand),
|
||||
Arc::new(toggle_mouse_reporting::ToggleMouseReportingCommand),
|
||||
Arc::new(settings_cmd::SettingsCommand),
|
||||
Arc::new(privacy::PrivacyCommand),
|
||||
Arc::new(rewind::RewindCommand),
|
||||
Arc::new(jump::JumpCommand),
|
||||
Arc::new(login::LoginCommand),
|
||||
@@ -455,83 +451,24 @@ mod tests {
|
||||
usage::UsageCommand.run(&mut ctx, args)
|
||||
}
|
||||
#[test]
|
||||
fn usage_no_args_returns_show_usage() {
|
||||
fn usage_returns_show_usage() {
|
||||
assert!(matches!(
|
||||
run_usage(""),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_show_returns_show_usage() {
|
||||
fn usage_ignores_stray_args() {
|
||||
assert!(matches!(
|
||||
run_usage("show"),
|
||||
run_usage(" anything "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_manage_returns_open_url() {
|
||||
match run_usage("manage") {
|
||||
CommandResult::Action(Action::OpenUrl(url)) => {
|
||||
assert_eq!(url, "https://grok.com/?_s=usage");
|
||||
}
|
||||
other => panic!("expected Action(OpenUrl), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_invalid_arg_returns_error() {
|
||||
match run_usage("delete") {
|
||||
CommandResult::Error(msg) => {
|
||||
assert!(msg.contains("delete"), "got: {msg}");
|
||||
}
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_whitespace_only_treated_as_no_args() {
|
||||
assert!(matches!(
|
||||
run_usage(" "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_show_with_leading_whitespace() {
|
||||
assert!(matches!(
|
||||
run_usage(" show "),
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn usage_manage_with_leading_whitespace() {
|
||||
match run_usage(" manage ") {
|
||||
CommandResult::Action(Action::OpenUrl(url)) => {
|
||||
assert_eq!(url, "https://grok.com/?_s=usage");
|
||||
}
|
||||
other => panic!("expected Action(OpenUrl), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn usage_suggest_args_returns_show_and_manage() {
|
||||
let models = ModelState::default();
|
||||
let ctx = crate::slash::command::AppCtx {
|
||||
models: &models,
|
||||
cwd: std::path::Path::new("."),
|
||||
screen_mode: crate::app::ScreenMode::Fullscreen,
|
||||
};
|
||||
let items = usage::UsageCommand
|
||||
.suggest_args(&ctx, "")
|
||||
.expect("should have suggestions");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].display, "show");
|
||||
assert_eq!(items[0].insert_text, "show");
|
||||
assert_eq!(items[1].display, "manage");
|
||||
assert_eq!(items[1].insert_text, "manage");
|
||||
}
|
||||
#[test]
|
||||
fn usage_metadata() {
|
||||
let cmd = usage::UsageCommand;
|
||||
assert_eq!(cmd.name(), "usage");
|
||||
assert!(cmd.takes_args());
|
||||
assert_eq!(cmd.arg_placeholder(), Some("show | manage"));
|
||||
assert!(!cmd.takes_args());
|
||||
assert!(!cmd.description().is_empty());
|
||||
assert!(!cmd.usage().is_empty());
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
//! `/privacy` -- show or toggle privacy and data retention status.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show or toggle privacy and data retention status.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `/privacy` show current status
|
||||
/// - `/privacy opt-in` opt in to coding data sharing
|
||||
/// - `/privacy opt-out` opt out of coding data sharing
|
||||
///
|
||||
/// Case-insensitive. Only unambiguous aliases are accepted (e.g. `in`,
|
||||
/// `share`, `out`, `private`) — generic toggles like `on`/`off` are
|
||||
/// rejected because they're ambiguous in privacy context.
|
||||
pub struct PrivacyCommand;
|
||||
|
||||
impl SlashCommand for PrivacyCommand {
|
||||
fn name(&self) -> &str {
|
||||
"privacy"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Show or toggle privacy & data retention status"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/privacy [opt-in|opt-out]"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let arg = args.trim();
|
||||
if arg.is_empty() {
|
||||
return CommandResult::Action(Action::ShowPrivacyInfo);
|
||||
}
|
||||
match parse_privacy_arg(arg) {
|
||||
Some(opted_in) => CommandResult::Action(Action::SetCodingDataSharing { opted_in }),
|
||||
None => CommandResult::Error(format!(
|
||||
"Unknown argument `{arg}`. Valid options: opt-in (aliases: in, share) | \
|
||||
opt-out (aliases: out, private)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `/privacy <arg>` into `Some(true)` (opt-in), `Some(false)`
|
||||
/// (opt-out), or `None` (unknown). Case-insensitive ASCII matching.
|
||||
#[doc(hidden)]
|
||||
pub fn parse_privacy_arg(arg: &str) -> Option<bool> {
|
||||
const OPT_IN_ALIASES: &[&str] = &["opt-in", "in", "share"];
|
||||
const OPT_OUT_ALIASES: &[&str] = &["opt-out", "out", "private"];
|
||||
|
||||
if OPT_IN_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
|
||||
return Some(true);
|
||||
}
|
||||
if OPT_OUT_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
|
||||
return Some(false);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_opt_in_canonical() {
|
||||
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opt_out_canonical() {
|
||||
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
|
||||
}
|
||||
|
||||
/// Case-insensitive matching.
|
||||
#[test]
|
||||
fn parse_case_insensitive() {
|
||||
for variant in &["OPT-IN", "Opt-In", "opt-IN", "OpT-iN"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(variant),
|
||||
Some(true),
|
||||
"case-insensitive parse must accept `{variant}` as opt-in",
|
||||
);
|
||||
}
|
||||
for variant in &["OPT-OUT", "Opt-Out", "opt-OUT", "OpT-oUt"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(variant),
|
||||
Some(false),
|
||||
"case-insensitive parse must accept `{variant}` as opt-out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins the accepted alias catalog.
|
||||
#[test]
|
||||
fn parse_opt_in_aliases() {
|
||||
for alias in &["in", "share"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(alias),
|
||||
Some(true),
|
||||
"alias `{alias}` must map to opt-in",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opt_out_aliases() {
|
||||
for alias in &["out", "private"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(alias),
|
||||
Some(false),
|
||||
"alias `{alias}` must map to opt-out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ambiguous generic-toggle aliases must be rejected — `/privacy on`
|
||||
/// is ambiguous (could mean opt-in or opt-out).
|
||||
#[test]
|
||||
fn parse_rejects_ambiguous_generic_aliases() {
|
||||
for ambiguous in &[
|
||||
"on", "off", "true", "false", "enable", "enabled", "disable", "disabled",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(ambiguous),
|
||||
None,
|
||||
"ambiguous alias `{ambiguous}` MUST be rejected — it would let a user typing \
|
||||
`/privacy {ambiguous}` get the OPPOSITE of their intent in privacy context. \
|
||||
See Security Issue 10 in PR 9 R1.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown arguments return None → the command surfaces an error
|
||||
/// listing valid options. Pins the "no silent fallback" contract.
|
||||
#[test]
|
||||
fn parse_unknown_returns_none() {
|
||||
for unknown in &["yes", "no", "maybe", "opt-maybe", "", " ", "1", "0"] {
|
||||
assert_eq!(
|
||||
parse_privacy_arg(unknown),
|
||||
None,
|
||||
"unknown arg `{unknown}` must NOT parse",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias families must not overlap.
|
||||
#[test]
|
||||
fn alias_families_disjoint() {
|
||||
let opt_in_results: Vec<bool> = ["opt-in", "in", "share"]
|
||||
.iter()
|
||||
.map(|a| parse_privacy_arg(a).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
opt_in_results.iter().all(|b| *b),
|
||||
"every opt-in alias must parse to true",
|
||||
);
|
||||
let opt_out_results: Vec<bool> = ["opt-out", "out", "private"]
|
||||
.iter()
|
||||
.map(|a| parse_privacy_arg(a).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
opt_out_results.iter().all(|b| !*b),
|
||||
"every opt-out alias must parse to false",
|
||||
);
|
||||
}
|
||||
|
||||
/// Error message must list every accepted alias.
|
||||
#[test]
|
||||
fn error_message_lists_all_accepted_aliases() {
|
||||
use crate::acp::model_state::ModelState;
|
||||
use crate::app::bundle::BundleState;
|
||||
|
||||
let cmd = PrivacyCommand;
|
||||
let models = ModelState::default();
|
||||
let bundle = BundleState::default();
|
||||
let mut ctx = CommandExecCtx {
|
||||
models: &models,
|
||||
session_id: None,
|
||||
bundle_state: &bundle,
|
||||
screen_mode: crate::app::ScreenMode::Inline,
|
||||
pager_state: crate::settings::PagerLocalSnapshot::default(),
|
||||
};
|
||||
let result = cmd.run(&mut ctx, "garbage-input");
|
||||
match result {
|
||||
CommandResult::Error(msg) => {
|
||||
// Every accepted alias appears in the error message.
|
||||
for alias in &["opt-in", "in", "share", "opt-out", "out", "private"] {
|
||||
assert!(
|
||||
msg.contains(alias),
|
||||
"error message must mention alias `{alias}` so the user knows \
|
||||
what to type; msg = {msg:?}",
|
||||
);
|
||||
}
|
||||
// Dropped ambiguous aliases must not appear.
|
||||
for dropped in &["off", "true", "false", "enable", "disable"] {
|
||||
assert!(
|
||||
!msg.contains(dropped),
|
||||
"dropped alias `{dropped}` must NOT appear in error message \
|
||||
(would suggest it's still accepted); msg = {msg:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected Error result for unknown arg, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! `/share` -- share current session via URL.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Share the current session via a public URL.
|
||||
pub struct ShareCommand;
|
||||
|
||||
impl SlashCommand for ShareCommand {
|
||||
fn name(&self) -> &str {
|
||||
"share"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Share this session via URL"
|
||||
}
|
||||
|
||||
fn session_scoped(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/share"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
// Check if we have an active session
|
||||
if ctx.session_id.is_none() {
|
||||
return CommandResult::Error("No active session to share".to_string());
|
||||
}
|
||||
|
||||
CommandResult::Action(Action::ShareSession)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
//! `/usage` -- show credit usage or open billing management page.
|
||||
//! `/usage` -- display Kimi API usage and quota information.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show coding credit usage or manage billing.
|
||||
///
|
||||
/// `/usage` -- show current credit usage
|
||||
/// `/usage show` -- same as above
|
||||
/// `/usage manage` -- open billing management page in browser
|
||||
/// Display API usage and quota information.
|
||||
pub struct UsageCommand;
|
||||
|
||||
impl SlashCommand for UsageCommand {
|
||||
@@ -15,56 +11,22 @@ impl SlashCommand for UsageCommand {
|
||||
"usage"
|
||||
}
|
||||
|
||||
/// `/cost` is the minimal-mode name for the same credit-usage summary:
|
||||
/// it commits a usage/cost system block rather than opening a
|
||||
/// pane, so it's an alias rather than a separate command.
|
||||
/// `/cost` is the minimal-mode name for the same usage summary: it
|
||||
/// commits a usage system block rather than opening a pane, so it's
|
||||
/// an alias rather than a separate command.
|
||||
fn aliases(&self) -> &[&str] {
|
||||
&["cost"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"View credit usage or manage billing"
|
||||
"Display API usage and quota information"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/usage [show|manage]"
|
||||
"/usage"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("show | manage")
|
||||
}
|
||||
|
||||
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
|
||||
Some(vec![
|
||||
ArgItem {
|
||||
display: "show".to_string(),
|
||||
match_text: "show".to_string(),
|
||||
insert_text: "show".to_string(),
|
||||
description: "View credit usage".to_string(),
|
||||
},
|
||||
ArgItem {
|
||||
display: "manage".to_string(),
|
||||
match_text: "manage".to_string(),
|
||||
insert_text: "manage".to_string(),
|
||||
description: "Open billing management page".to_string(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let arg = args.trim();
|
||||
match arg {
|
||||
"" | "show" => CommandResult::Action(Action::ShowUsage),
|
||||
"manage" => {
|
||||
CommandResult::Action(Action::OpenUrl("https://grok.com/?_s=usage".to_string()))
|
||||
}
|
||||
_ => CommandResult::Error(format!(
|
||||
"Unknown argument: {arg}. Use /usage show or /usage manage"
|
||||
)),
|
||||
}
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
CommandResult::Action(Action::ShowUsage)
|
||||
}
|
||||
}
|
||||
@@ -1828,7 +1828,6 @@ mod tests {
|
||||
"/compact",
|
||||
"/fork",
|
||||
"/rewind",
|
||||
"/share",
|
||||
"/context",
|
||||
"/copy",
|
||||
"/export",
|
||||
|
||||
@@ -351,12 +351,6 @@ impl CommandRegistry {
|
||||
self.available_tools = Some(tools);
|
||||
}
|
||||
|
||||
/// Show or hide the /share command.
|
||||
/// When hidden, it won't appear in the dropdown or be executable.
|
||||
pub fn set_share_visible(&mut self, visible: bool) {
|
||||
self.set_command_visible("share", visible);
|
||||
}
|
||||
|
||||
/// Show or hide the /usage command.
|
||||
/// When hidden, it won't appear in the dropdown or be executable.
|
||||
pub fn set_usage_visible(&mut self, visible: bool) {
|
||||
@@ -699,35 +693,6 @@ mod tests {
|
||||
assert!(registry.get("flush").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_share_visible_hides_and_restores_share_command() {
|
||||
let share: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "share",
|
||||
aliases: &[],
|
||||
});
|
||||
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "exit",
|
||||
aliases: &[],
|
||||
});
|
||||
let mut registry = CommandRegistry::new(vec![share, other]);
|
||||
|
||||
// Default: /share is visible.
|
||||
assert!(registry.get("share").is_some());
|
||||
assert!(registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
|
||||
// Hiding /share removes it from lookup and triggers.
|
||||
registry.set_share_visible(false);
|
||||
assert!(registry.get("share").is_none());
|
||||
assert!(!registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
// Other commands are unaffected.
|
||||
assert!(registry.get("exit").is_some());
|
||||
|
||||
// Re-enabling restores it.
|
||||
registry.set_share_visible(true);
|
||||
assert!(registry.get("share").is_some());
|
||||
assert!(registry.triggers().iter().any(|t| t.canonical == "share"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_usage_visible_hides_and_restores_usage_command() {
|
||||
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
@@ -1110,9 +1075,9 @@ mod tests {
|
||||
/// unresolvable for dispatch, exactly like `get()`.
|
||||
#[test]
|
||||
fn get_for_dispatch_respects_hard_gates() {
|
||||
// Hard-hidden by name (e.g. /dashboard default, /share toggle).
|
||||
let share: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "share",
|
||||
// Hard-hidden by name (e.g. /dashboard default).
|
||||
let dashboard: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
|
||||
name: "dashboard",
|
||||
aliases: &[],
|
||||
});
|
||||
// Tier-restricted.
|
||||
@@ -1125,11 +1090,14 @@ mod tests {
|
||||
name: "loop",
|
||||
required: &["scheduler_create"],
|
||||
});
|
||||
let mut reg = CommandRegistry::new(vec![share, usage, gated]);
|
||||
reg.set_share_visible(false);
|
||||
let mut reg = CommandRegistry::new(vec![dashboard, usage, gated]);
|
||||
reg.set_dashboard_visible(false);
|
||||
reg.set_restricted_commands(&["usage".to_string()]);
|
||||
|
||||
assert!(reg.get_for_dispatch("share").is_none(), "hidden stays hard");
|
||||
assert!(
|
||||
reg.get_for_dispatch("dashboard").is_none(),
|
||||
"hidden stays hard"
|
||||
);
|
||||
assert!(
|
||||
reg.get_for_dispatch("usage").is_none(),
|
||||
"restricted stays blocked (upsell path owns it)"
|
||||
|
||||
Reference in new issue
Block a user