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
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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user