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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user