From 94d15648047af6a5c5692e698ab1976f0ac7edf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 22 Sep 2026 21:05:50 +0200 Subject: [PATCH] feat(mobile): auto-open first-run tour only for zero-usage accounts on cold boot https://github.com/Kilo-Org/cloud/pull/6327 --- .../app-unlock-screen.test-helpers.tsx | 13 +- .../manual-review-screen.mounted.test.tsx | 7 + .../tour/tour-auto-open.mounted.test.tsx | 207 +++++++++++++++++- .../src/components/tour/tour-auto-open.tsx | 90 ++++++-- .../ui/text.rtl-tracking.mounted.test.tsx | 10 +- .../src/lib/tour/tour-auto-open-boot.test.ts | 47 ++++ .../src/lib/tour/tour-auto-open-boot.ts | 38 ++++ .../src/lib/tour/use-tour-gate-usage.ts | 19 ++ .../ai-gateway/openai-chatgpt/routing.test.ts | 17 ++ .../lib/ai-gateway/openai-chatgpt/routing.ts | 5 +- apps/web/src/routers/user-router.test.ts | 112 ++++++++++ apps/web/src/routers/user-router.ts | 15 ++ services/kilo-mcp/catalog.json | 11 + 13 files changed, 563 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/lib/tour/tour-auto-open-boot.test.ts create mode 100644 apps/mobile/src/lib/tour/tour-auto-open-boot.ts create mode 100644 apps/mobile/src/lib/tour/use-tour-gate-usage.ts diff --git a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx index 48cf092e4fe..261629f6152 100644 --- a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx +++ b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx @@ -229,7 +229,18 @@ vi.mock('@/lib/trpc', () => ({ TRPCProvider: 'TRPCProvider', trpcClient: {}, useTRPC: () => ({ - user: { getMe: { queryKey: () => [] } }, + user: { + getMe: { queryKey: () => [] }, + // The (app) layout's TourAutoOpen reads the account's gateway usage; + // give it a settled zero-usage answer so the tour gate stays closed in + // these layout scenes (the signed-out user id already blocks the push). + hasGatewayUsage: { + queryOptions: () => ({ + queryKey: ['user', 'hasGatewayUsage'], + queryFn: () => ({ hasUsage: false }), + }), + }, + }, organizations: { list: { queryKey: () => [] } }, }), })); diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx index 017063ef4fb..de64fd487cb 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx @@ -87,6 +87,13 @@ vi.mock('@/components/ui/radio-group', () => ({ vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); +// The screen reserves the keyboard's height through this wrapper. Its module +// pulls `react-native-safe-area-context`, whose CommonJS entry loads the real +// React Native outside this suite's partial `react-native` mock, so stub it. +vi.mock('@/components/kilo-chat/app-aware-keyboard-padding', () => ({ + AppAwareKeyboardPaddingView: 'View', + useAppAwareKeyboardPadding: () => 0, +})); vi.mock('@/lib/code-reviewer-config', () => ({ PLATFORM_CAPABILITIES: { github: { label: 'GitHub' }, gitlab: { label: 'GitLab' } }, })); diff --git a/apps/mobile/src/components/tour/tour-auto-open.mounted.test.tsx b/apps/mobile/src/components/tour/tour-auto-open.mounted.test.tsx index 4b209aff089..a0db34cd556 100644 --- a/apps/mobile/src/components/tour/tour-auto-open.mounted.test.tsx +++ b/apps/mobile/src/components/tour/tour-auto-open.mounted.test.tsx @@ -12,6 +12,16 @@ const state = vi.hoisted(() => ({ pathname: '/(app)/(tabs)/(0_home)', })); +// The once-per-process cold-boot marker. Controllable so a case can start a +// launch already spent (a warm entry) or assert the attempt was consumed. The +// launch-account binding lives in the same module state, so it also survives a +// remount of the gate (a sign-out then a different sign-in). +const boot = vi.hoisted(() => ({ spent: false, attemptUserId: null as string | null })); + +// The account's gateway-usage read. `isLoaded` true means the server answered; +// `hasUsage` is the account's answer once it did. +const usage = vi.hoisted(() => ({ isLoaded: true, hasUsage: false })); + // The account's consent-gate state, standing in for the SecureStore record the // bootstrap gate reads. `accepted` by default: the pre-existing cases assume // consent is already on file. @@ -68,6 +78,24 @@ vi.mock('@/lib/tour/tour-completion', () => ({ }), })); +vi.mock('@/lib/tour/tour-auto-open-boot', () => ({ + isTourAutoOpenAttemptSpent: () => boot.spent, + spendTourAutoOpenAttempt: () => { + boot.spent = true; + }, + claimTourAutoOpenAttempt: (userId: string) => { + if (boot.attemptUserId === null) { + boot.attemptUserId = userId; + return true; + } + return boot.attemptUserId === userId; + }, +})); + +vi.mock('@/lib/tour/use-tour-gate-usage', () => ({ + useTourGatewayUsage: () => ({ isLoaded: usage.isLoaded, hasUsage: usage.hasUsage }), +})); + vi.mock('@/lib/consent-gate', () => ({ checkConsentGate: () => consentGate.check(), })); @@ -109,18 +137,23 @@ describe('TourAutoOpen', () => { beforeEach(() => { routerPush.mockReset(); consentGate.reset(); + boot.spent = false; + boot.attemptUserId = null; + usage.isLoaded = true; + usage.hasUsage = false; state.userId = 'user-1'; state.isLoaded = true; state.isCompleted = false; state.pathname = '/(app)/(tabs)/(0_home)'; }); - it('pushes the tour exactly once on a first sign-in that has not completed it', async () => { + it('pushes the tour exactly once on a cold boot with zero gateway usage', async () => { const renderer = mountAutoOpen(); await flushConsentRead(); expect(routerPush).toHaveBeenCalledTimes(1); expect(routerPush).toHaveBeenCalledWith('/(app)/tour'); + expect(boot.spent).toBe(true); // A later render for the same account must not re-open the tour. rerender(renderer); @@ -129,6 +162,156 @@ describe('TourAutoOpen', () => { renderer.unmount(); }); + it('never pushes for an account with gateway usage, and spends the attempt', async () => { + usage.hasUsage = true; + const renderer = mountAutoOpen(); + await flushConsentRead(); + + expect(routerPush).not.toHaveBeenCalled(); + // The launch attempt is consumed, so an account switch later in this + // process cannot auto-open for a zero-usage account either. + expect(boot.spent).toBe(true); + + rerender(renderer); + expect(routerPush).not.toHaveBeenCalled(); + + renderer.unmount(); + }); + + it('never pushes on a warm entry after the attempt is spent', async () => { + boot.spent = true; + const renderer = mountAutoOpen(); + await flushConsentRead(); + + expect(routerPush).not.toHaveBeenCalled(); + + rerender(renderer); + expect(routerPush).not.toHaveBeenCalled(); + + renderer.unmount(); + }); + + it('holds on an unanswered usage read, then opens when the read succeeds in the same launch', async () => { + usage.isLoaded = false; + const renderer = mountAutoOpen(); + await flushConsentRead(); + + // The read has not answered: hold, and do not spend the launch attempt. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(false); + + // The read succeeds with zero usage later in the same launch. + usage.isLoaded = true; + rerender(renderer); + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith('/(app)/tour'); + expect(boot.spent).toBe(true); + + renderer.unmount(); + }); + + // A warm account switch must not inherit the launch's unspent attempt: if the + // launch account held (a completion or usage read never answered), a different + // account signing in later in the same process must not auto-open the tour. + it.each(['completion', 'usage'])( + 'never auto-opens after a warm switch during %s loading', + async hold => { + state.isLoaded = hold !== 'completion'; + usage.isLoaded = hold !== 'usage'; + const renderer = mountAutoOpen(); + await flushConsentRead(); + + // The launch account holds on the unanswered read: no push, unspent. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(false); + + // A different account signs in later in the same process with zero usage. + state.userId = 'user-2'; + state.isLoaded = true; + usage.isLoaded = true; + rerender(renderer); + await flushConsentRead(); + + // The launch attempt belongs to the first account, so the switch spends it + // instead of opening the tour. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(true); + + rerender(renderer); + expect(routerPush).not.toHaveBeenCalled(); + + renderer.unmount(); + } + ); + + // The consent hold is the other hold that leaves the attempt unspent. The + // launch account never answered the gate, so a different account signing in + // later in the same process must not inherit the attempt either. + it('does not auto-open for a warm account switch after a consent hold', async () => { + consentGate.status = 'needs-consent'; + const renderer = mountAutoOpen(); + await flushConsentRead(); + + // The launch account holds on the unanswered gate: no push, unspent. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(false); + + // A different account signs in later in the same process, with consent on + // file and zero usage. + state.userId = 'user-2'; + consentGate.status = 'accepted'; + rerender(renderer); + await flushConsentRead(); + + // The launch attempt belongs to the first account, so the switch spends it + // instead of opening the tour. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(true); + + rerender(renderer); + expect(routerPush).not.toHaveBeenCalled(); + + renderer.unmount(); + }); + + // Sign-out unmounts the `(app)` tree, including this gate, while the + // process-wide attempt survives. A different account signing in remounts the + // gate: the launch-account binding must survive that remount, or the second + // account looks like the launch account and auto-opens the tour. + it.each(['completion', 'usage'])( + 'never auto-opens after remounting during %s loading', + async hold => { + state.isLoaded = hold !== 'completion'; + usage.isLoaded = hold !== 'usage'; + const first = mountAutoOpen(); + await flushConsentRead(); + + // The launch account held its unspent attempt; the (app) tree then unmounts + // on the sign-out redirect. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(false); + first.unmount(); + + // A different account signs in and remounts the gate, with consent on file + // and zero usage. + state.userId = 'user-2'; + state.isLoaded = true; + usage.isLoaded = true; + const second = mountAutoOpen(); + await flushConsentRead(); + + // The launch attempt belongs to the first account, so the remount spends it + // instead of opening the tour. + expect(routerPush).not.toHaveBeenCalled(); + expect(boot.spent).toBe(true); + + rerender(second); + expect(routerPush).not.toHaveBeenCalled(); + + second.unmount(); + } + ); + it('never pushes once the account has finished or skipped the tour', async () => { state.isCompleted = true; const renderer = mountAutoOpen(); @@ -148,30 +331,44 @@ describe('TourAutoOpen', () => { rerender(renderer); expect(routerPush).not.toHaveBeenCalled(); + expect(boot.attemptUserId).toBeNull(); + expect(boot.spent).toBe(false); renderer.unmount(); }); - it('never pushes before the stored decision has loaded', async () => { + it('holds the launch account attempt until its stored decision has loaded', async () => { state.isLoaded = false; const renderer = mountAutoOpen(); await flushConsentRead(); rerender(renderer); expect(routerPush).not.toHaveBeenCalled(); + expect(boot.attemptUserId).toBe('user-1'); + expect(boot.spent).toBe(false); + + state.isLoaded = true; + rerender(renderer); + expect(routerPush).toHaveBeenCalledWith('/(app)/tour'); + expect(boot.spent).toBe(true); + + rerender(renderer); + expect(routerPush).toHaveBeenCalledTimes(1); renderer.unmount(); }); - it('does not push when the tour route is already active and never pushes later', async () => { + it('does not push when the tour route is already active and never re-arms the automatic open', async () => { + // A manual replay (the Profile Tutorial row) put the tour on screen. state.pathname = '/(app)/tour'; const renderer = mountAutoOpen(); await flushConsentRead(); expect(routerPush).not.toHaveBeenCalled(); + // The explicit open consumed the launch attempt. + expect(boot.spent).toBe(true); - // The person dismissed the tour (or opened it from Profile); navigating - // away must not push it behind them. + // Navigating away must not push it behind them. state.pathname = '/(app)/(tabs)/(3_profile)'; rerender(renderer); expect(routerPush).not.toHaveBeenCalled(); diff --git a/apps/mobile/src/components/tour/tour-auto-open.tsx b/apps/mobile/src/components/tour/tour-auto-open.tsx index 7d9a5673371..ff0b3dd34fd 100644 --- a/apps/mobile/src/components/tour/tour-auto-open.tsx +++ b/apps/mobile/src/components/tour/tour-auto-open.tsx @@ -4,7 +4,13 @@ import { useEffect, useRef, useState } from 'react'; import { subscribeToConsentChanges } from '@/lib/consent'; import { checkConsentGate } from '@/lib/consent-gate'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { + claimTourAutoOpenAttempt, + isTourAutoOpenAttemptSpent, + spendTourAutoOpenAttempt, +} from '@/lib/tour/tour-auto-open-boot'; import { useTourCompletion } from '@/lib/tour/tour-completion'; +import { useTourGatewayUsage } from '@/lib/tour/use-tour-gate-usage'; const TOUR_ROUTE = '/(app)/tour'; @@ -61,15 +67,28 @@ function useConsentGateState(userId: string | undefined): ConsentGateState { } /** - * Auto-opens the first-sign-in tour exactly once per account. + * Auto-opens the first-sign-in tour once per account, on a cold boot only, and + * only for an account with zero Kilo gateway usage. + * + * Current behaviour before this change: the component is mounted + * unconditionally (`apps/mobile/src/app/(app)/_layout.tsx`) and pushed the tour + * once per account after consent, for every account. There is no tour feature + * flag or build config. The only permanent suppressions are the per-account + * completion record and an unanswered consent gate, so the owner's "never + * shown" reading is account-local state, not a code path. This change adds the + * zero-usage condition and restricts the automatic open to the launch that + * created this JS process. * * Fires only when a user id is present, the stored decision has loaded, the * account has not already finished or skipped the tour, the account's consent - * gate has been answered, and the tour route is not already on screen. A ref - * keyed by user id makes the push once-only for that account, so later - * renders (and later sign-ins of the same account) never re-open it — the - * persisted decision reinforces that for a fresh mount. Returns null; it is - * mounted in the `(app)` layout beside the other mounts. + * gate has been answered, the tour route is not already on screen, this + * process has not spent its one attempt, and the server has answered that the + * account has no gateway usage. A ref keyed by user id makes the push + * once-only for that account, so later renders (and later sign-ins of the same + * account) never re-open it — the persisted decision reinforces that for a + * fresh mount. The attempt is bound to the first account this process sees, so + * a warm account switch after a held launch cannot auto-open either. Returns + * null; it is mounted in the `(app)` layout beside the other mounts. * * A brand-new account signs in behind the consent gate, which is shown until * the person answers it. The gate cannot host the tour: its bootstrap guard @@ -81,6 +100,10 @@ function useConsentGateState(userId: string | undefined): ConsentGateState { * lands. Marking the account opened there would suppress the first-sign-in * tour for the whole session, and the tour would only surface on some later * cold start. + * + * Usage unknown (loading or failed) also holds without spending the attempt, + * so a later success in the same launch can still open the tour for a + * zero-usage account; a used account is never opened on an unknown value. */ export function TourAutoOpen() { const router = useRouter(); @@ -88,33 +111,70 @@ export function TourAutoOpen() { const { userId } = useCurrentUserId(); const { isLoaded, isCompleted } = useTourCompletion(userId); const consentGate = useConsentGateState(userId); + const { isLoaded: usageLoaded, hasUsage } = useTourGatewayUsage( + userId, + !isTourAutoOpenAttemptSpent() + ); const openedForRef = useRef(null); useEffect(() => { - if (!userId || !isLoaded || isCompleted) { + if (!userId) { return; } - // Already opened for this account, or the tour is on screen (an explicit - // Profile open): mark it opened so we never push behind the person later. if (openedForRef.current === userId) { return; } - // Hold the once-only marker while the account still owes consent: a push - // now is bounced back by the bootstrap guard. + // One automatic attempt per app process. A warm (app) entry or a resume + // must never auto-open; only the launch that created this process may. + if (isTourAutoOpenAttemptSpent()) { + return; + } + // The process has one automatic attempt, and it belongs to the launch + // account: the first account this gate saw after a cold boot. A warm + // account switch — including a sign-out, which unmounts the (app) tree, and + // a different sign-in, which remounts it — must not inherit the attempt, so + // spend it there. The binding lives in the boot marker's module state, not a + // ref, so the remount cannot mistake the second account for the launch + // account. The launch account's own completion, consent, or usage hold + // still keeps the attempt for a later success in this same launch. + if (!claimTourAutoOpenAttempt(userId)) { + spendTourAutoOpenAttempt(); + return; + } + if (!isLoaded) { + return; + } + // An account that finished the tour can never auto-open; spend the attempt + // so an account switch later in this same process cannot either. + if (isCompleted) { + spendTourAutoOpenAttempt(); + return; + } if (consentGate !== 'accepted') { return; } - // Never push from the gate itself: the answer's redirect out of the gate - // can still be a frame away, and a push from the gate is bounced. if (CONSENT_PATHNAMES.has(pathname)) { return; } - openedForRef.current = userId; + // An explicit Profile open put the tour on screen: consume the launch + // attempt so it never pushes behind the person later in this process. if (TOUR_PATHNAMES.has(pathname)) { + openedForRef.current = userId; + spendTourAutoOpenAttempt(); + return; + } + // Usage unknown (loading or failed): hold. Never spend the attempt or push + // on an unknown, or a used account could open the tour. + if (!usageLoaded) { + return; + } + openedForRef.current = userId; + spendTourAutoOpenAttempt(); + if (hasUsage) { return; } router.push(TOUR_ROUTE as Href); - }, [userId, isLoaded, isCompleted, consentGate, pathname, router]); + }, [userId, isLoaded, isCompleted, consentGate, pathname, router, usageLoaded, hasUsage]); return null; } diff --git a/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx b/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx index 043a0bfbe0e..15a5376e64b 100644 --- a/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx +++ b/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx @@ -105,13 +105,13 @@ describe('Text tracked labels in RTL', () => { // The eyebrow variant's Latin display class is LTR-only (see `Text`'s // eyebrow variant and `SectionHeader`), so in RTL the shared label carries // no tracked class and the reset style is its whole treatment. - expect(hostText(root).props.className as string).not.toContain('tracking-[1.5px]'); - // The eyebrow variant owns its Latin display classes, so an RTL eyebrow - // drops them (the rule `text.mounted.test.tsx` pins) rather than keeping - // them like a caller-supplied tracked class; no uppercase/tracked class is - // left for the shared reset to neutralize on this label, and the zero + // The eyebrow variant owns its Latin display classes and drops them in + // RTL (the rule `text.mounted.test.tsx` pins), rather than keeping them + // like a caller-supplied tracked class; no uppercase/tracked class is left + // for the shared reset to neutralize on this label, and the zero // letter-spacing reset still lands. const classes = (hostText(root).props.className as string).split(' '); + expect(hostText(root).props.className as string).not.toContain('tracking-[1.5px]'); expect(classes).not.toContain('uppercase'); expect(classes.some(name => name.startsWith('tracking'))).toBe(false); expect(hostText(root).props.className as string).not.toContain('tracking-'); diff --git a/apps/mobile/src/lib/tour/tour-auto-open-boot.test.ts b/apps/mobile/src/lib/tour/tour-auto-open-boot.test.ts new file mode 100644 index 00000000000..710d75bda93 --- /dev/null +++ b/apps/mobile/src/lib/tour/tour-auto-open-boot.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isTourAutoOpenAttemptSpent, spendTourAutoOpenAttempt } from './tour-auto-open-boot'; + +describe('tour auto-open boot marker', () => { + it('is unspent at process start', () => { + expect(isTourAutoOpenAttemptSpent()).toBe(false); + }); + + it('is spent after the attempt is spent', () => { + spendTourAutoOpenAttempt(); + expect(isTourAutoOpenAttemptSpent()).toBe(true); + }); + + it('stays spent after a second call, so a warm entry never re-arms it', () => { + spendTourAutoOpenAttempt(); + spendTourAutoOpenAttempt(); + expect(isTourAutoOpenAttemptSpent()).toBe(true); + }); +}); + +// The launch-account binding is module state, so each case starts from a fresh +// module to model one cold boot. +describe('tour auto-open launch account binding', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('binds to the first account seen and keeps the same account', async () => { + const { claimTourAutoOpenAttempt } = await import('./tour-auto-open-boot'); + + expect(claimTourAutoOpenAttempt('launch-user')).toBe(true); + // A later render or sign-in of the launch account still owns the attempt. + expect(claimTourAutoOpenAttempt('launch-user')).toBe(true); + }); + + it('rejects a later account, so a warm switch cannot inherit the attempt', async () => { + const { claimTourAutoOpenAttempt } = await import('./tour-auto-open-boot'); + + expect(claimTourAutoOpenAttempt('launch-user')).toBe(true); + expect(claimTourAutoOpenAttempt('other-user')).toBe(false); + // A rejection must not rebind, or the next render could open for the + // switched-in account. + expect(claimTourAutoOpenAttempt('other-user')).toBe(false); + expect(claimTourAutoOpenAttempt('launch-user')).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/tour/tour-auto-open-boot.ts b/apps/mobile/src/lib/tour/tour-auto-open-boot.ts new file mode 100644 index 00000000000..fe903ca99d0 --- /dev/null +++ b/apps/mobile/src/lib/tour/tour-auto-open-boot.ts @@ -0,0 +1,38 @@ +/** + * The once-per-process marker for the tour's automatic open. + * + * Module state is evaluated once per JS process, i.e. once per cold boot. A + * warm entry into the `(app)` area or a foreground resume re-renders the gate + * without re-evaluating this module, so it can never re-arm the automatic + * open. Spending the marker is one-way: nothing clears it, and a manual open + * from Profile must not spend it for a later launch. + */ +let attemptSpent = false; +let attemptUserId: string | null = null; + +/** Whether this process has already used its one automatic open attempt. */ +export function isTourAutoOpenAttemptSpent(): boolean { + return attemptSpent; +} + +/** Marks the automatic open attempt as used for the rest of this process. */ +export function spendTourAutoOpenAttempt(): void { + attemptSpent = true; +} + +/** + * Binds the process's one automatic attempt to the first account the gate sees + * after a cold boot, and reports whether the attempt belongs to `userId`. + * + * The binding lives in module state rather than a component ref so it survives + * a remount of the `(app)` tree: a sign-out unmounts the gate while the + * process-wide attempt survives, so a different account signing in later in the + * same process is not the launch account and cannot inherit the attempt. + */ +export function claimTourAutoOpenAttempt(userId: string): boolean { + if (attemptUserId === null) { + attemptUserId = userId; + return true; + } + return attemptUserId === userId; +} diff --git a/apps/mobile/src/lib/tour/use-tour-gate-usage.ts b/apps/mobile/src/lib/tour/use-tour-gate-usage.ts new file mode 100644 index 00000000000..a89ae586665 --- /dev/null +++ b/apps/mobile/src/lib/tour/use-tour-gate-usage.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/lib/trpc'; + +/** + * Whether the account has any Kilo gateway usage, for the tour's automatic + * open. `isLoaded` is true only after the server answered: a loading or failed + * read keeps the gate closed and leaves the launch attempt unspent, so a later + * success in the same launch can still open the tour for a zero-usage account, + * and an unknown value never opens it for a used one. + */ +export function useTourGatewayUsage(userId: string | undefined, enabled: boolean) { + const trpc = useTRPC(); + const query = useQuery({ + ...trpc.user.hasGatewayUsage.queryOptions(), + enabled: enabled && userId !== undefined, + }); + return { isLoaded: query.isSuccess, hasUsage: query.data?.hasUsage ?? false }; +} diff --git a/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.test.ts b/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.test.ts index b7ca5f1f205..9cef84c2bca 100644 --- a/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.test.ts +++ b/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.test.ts @@ -218,6 +218,23 @@ describe('isOpenAiChatGptEligible', () => { } ); + it.each([ + 'openai/gpt-5.6-sol-discounted', + 'openai/gpt-6-astra-flex', + ' OpenAI/gpt-5.6-sol-discounted ', + ' OpenAI/gpt-6-astra-flex ', + ])('excludes the disabled exclusive alias %p before any delegated lookup', async model => { + const input = routingInput({ request: responsesRequest(model), requestedModel: model }); + + await expect(isOpenAiChatGptEligible(input)).resolves.toBe(false); + await expect(checkOpenAiChatGptByok(input)).resolves.toBeNull(); + expect(getOpenAiChatGptStoredConnection).not.toHaveBeenCalled(); + expect(resolveOpenAiChatGptAccessToken).not.toHaveBeenCalled(); + + await expect(getOpenAiChatGptByokModelIds(USER_OWNER, [model])).resolves.toEqual(new Set()); + expect(isOpenAiModelServed).not.toHaveBeenCalled(); + }); + it.each(['', ' '])( 'stays eligible with an empty partner key %p, leaving the key to the provider', async apiKey => { diff --git a/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts b/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts index afc26804d80..d3b940b2d2a 100644 --- a/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts +++ b/apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts @@ -110,8 +110,9 @@ export type OpenAiChatGptRoutingResult = }; function isOpenAiChatGptModel(requestedModel: string): boolean { - const model = requestedModel.trim(); - // Retired Kilo aliases are not upstream model IDs either. + const model = requestedModel.trim().toLowerCase(); + // Disabling a Kilo-only alias must not turn it into a delegated OpenAI model; + // retired Kilo aliases are not upstream model IDs either. return ( OPENAI_MODEL_PREFIX.test(model) && !isGptOssModel(model) && diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 33327c40cc6..53a82665955 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -7,6 +7,7 @@ import { kiloclaw_instances, kilocode_users, magic_link_tokens, + microdollar_usage, organization_memberships, organizations, passkey_credentials, @@ -1961,6 +1962,117 @@ describe('user router - account deletion', () => { }); }); +describe('user router - hasGatewayUsage', () => { + let freshUser: User; + let usedUser: User; + let orgUser: User; + let otherUser: User; + + beforeAll(async () => { + freshUser = await insertTestUser({ + google_user_email: 'has-gateway-usage-fresh@example.com', + google_user_name: 'Gateway Usage Fresh', + }); + usedUser = await insertTestUser({ + google_user_email: 'has-gateway-usage-used@example.com', + google_user_name: 'Gateway Usage Used', + }); + orgUser = await insertTestUser({ + google_user_email: 'has-gateway-usage-org@example.com', + google_user_name: 'Gateway Usage Org', + }); + otherUser = await insertTestUser({ + google_user_email: 'has-gateway-usage-other@example.com', + google_user_name: 'Gateway Usage Other', + }); + + // A personal, non-zero-cost gateway row. + await db.insert(microdollar_usage).values({ + kilo_user_id: usedUser.id, + cost: 1000, + input_tokens: 1, + output_tokens: 1, + cache_write_tokens: 1, + cache_hit_tokens: 1, + }); + + // An org-scoped, zero-cost free-model row still counts as "used Kilo". + const org = await createTestOrganization('has-gateway-usage-org', orgUser.id, 0); + await db.insert(microdollar_usage).values({ + kilo_user_id: orgUser.id, + organization_id: org.id, + cost: 0, + input_tokens: 1, + output_tokens: 1, + cache_write_tokens: 1, + cache_hit_tokens: 1, + }); + + // Another user's row must never make this caller report usage. + await db.insert(microdollar_usage).values({ + kilo_user_id: otherUser.id, + cost: 1000, + input_tokens: 1, + output_tokens: 1, + cache_write_tokens: 1, + cache_hit_tokens: 1, + }); + }); + + afterAll(async () => { + await db + .delete(microdollar_usage) + .where( + inArray(microdollar_usage.kilo_user_id, [ + freshUser.id, + usedUser.id, + orgUser.id, + otherUser.id, + ]) + ); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.kilo_user_id, orgUser.id)); + await db.delete(organizations).where(eq(organizations.created_by_kilo_user_id, orgUser.id)); + await db + .delete(kilocode_users) + .where(inArray(kilocode_users.id, [freshUser.id, usedUser.id, orgUser.id, otherUser.id])); + }); + + it('reports no usage for a fresh user', async () => { + const caller = await createCallerForUser(freshUser.id); + + await expect(caller.user.hasGatewayUsage()).resolves.toEqual({ hasUsage: false }); + }); + + it('reports usage after a personal gateway row', async () => { + const caller = await createCallerForUser(usedUser.id); + + await expect(caller.user.hasGatewayUsage()).resolves.toEqual({ hasUsage: true }); + }); + + it('reports usage for an org-scoped row', async () => { + const caller = await createCallerForUser(orgUser.id); + + await expect(caller.user.hasGatewayUsage()).resolves.toEqual({ hasUsage: true }); + }); + + it("ignores another user's gateway rows", async () => { + const isolatedUser = await insertTestUser({ + google_user_email: `has-gateway-usage-isolated-${crypto.randomUUID()}@example.com`, + }); + + try { + const caller = await createCallerForUser(isolatedUser.id); + + await expect(caller.user.hasGatewayUsage()).resolves.toEqual({ hasUsage: false }); + } finally { + await db.delete(microdollar_usage).where(eq(microdollar_usage.kilo_user_id, isolatedUser.id)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, isolatedUser.id)); + } + }); +}); + describe('user router - passkeys', () => { let owner: User; let other: User; diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index a95cc82d904..b5f0926dc05 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -533,6 +533,21 @@ export const userRouter = createTRPCRouter({ }); }), + // Whether the caller has any Kilo gateway usage. Any `microdollar_usage` row + // for the user counts (org-scoped and zero-cost free-model rows included), so + // this reads the primary DB rather than a replica that may lag the first + // request. The lookup is an indexed `LIMIT 1` on + // `idx_kilo_user_id_created_at2`, so a cold-boot call is cheap. + hasGatewayUsage: baseProcedure + .output(z.object({ hasUsage: z.boolean() })) + .query(async ({ ctx }) => { + const row = await db.query.microdollar_usage.findFirst({ + where: eq(microdollar_usage.kilo_user_id, ctx.user.id), + columns: { id: true }, + }); + return { hasUsage: row !== undefined }; + }), + getAuthProviders: baseProcedure.query(async ({ ctx }) => { const providers = await getUserAuthProviders(ctx.user.id); diff --git a/services/kilo-mcp/catalog.json b/services/kilo-mcp/catalog.json index 535c3a56843..5d825c125a5 100644 --- a/services/kilo-mcp/catalog.json +++ b/services/kilo-mcp/catalog.json @@ -36621,6 +36621,17 @@ ], "searchBlob": "user.getPasskeys List all passkeys (WebAuthn security keys, Face ID, Touch ID) registered to the signed-in user's account, including device type and last used time. user getpasskeys" }, + "user.hasGatewayUsage": { + "path": "user.hasGatewayUsage", + "kind": "query", + "summary": "Check whether the current user has any Kilo gateway usage, including organization-scoped requests and zero-cost free-model requests.", + "inputSchema": {}, + "tags": [ + "user", + "hasgatewayusage" + ], + "searchBlob": "user.hasGatewayUsage Check whether the current user has any Kilo gateway usage, including organization-scoped requests and zero-cost free-model requests. user hasgatewayusage" + }, "user.linkAuthProvider": { "path": "user.linkAuthProvider", "kind": "mutation",