diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index e387a13e3..b077b8805 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -241,6 +241,13 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run grok on the computer running Orca/i) + expect(result.error).toMatch(/sign in if prompted/i) + expect(result.error).toMatch(/no chat message is needed/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) // Why: a stored-but-expired access token is refreshed by Grok CLI on next // use (a genuine sign-out returns 'missing'), so the message must not tell // users to re-run `grok login` (#8497). diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index eaa140eb2..1e6d20f5c 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -1,5 +1,9 @@ import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' import { isGrokAccessTokenFresh, readGrokAuthSession, @@ -47,14 +51,19 @@ type GrokBillingResponse = GrokBillingConfig & { config?: GrokBillingConfig } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { return { provider: 'grok', session: null, weekly: null, updatedAt: Date.now(), error, - status + status, + ...(usageMetadata ? { usageMetadata } : {}) } } @@ -225,7 +234,11 @@ export async function fetchGrokRateLimits( // Why: a genuine sign-out returns 'missing' earlier, so reaching here always // means a stored, refreshable session — Grok CLI refreshes the access token // on its next run, so don't tell users to re-run `grok login` (#8497). - return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') + return result( + 'error', + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index a6274097f..58f11dfdb 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -140,6 +140,11 @@ describe('fetchKimiRateLimits', () => { const result = await fetchKimiRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run kimi on the computer running Orca/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index f957e171d..778b90b79 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -208,8 +212,20 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { - return { provider: 'kimi', session: null, weekly: null, updatedAt: Date.now(), error, status } +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } } /** @@ -239,7 +255,11 @@ export async function fetchKimiRateLimits(): Promise { // Why: don't refresh — the CLI owns the token lifecycle. Report a transient // error so the rate-limit service keeps the last good snapshot (stale // policy) until the user next runs Kimi and the CLI refreshes the file. - return result('error', 'Kimi token expired — open Kimi to refresh') + return result( + 'error', + 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/renderer/src/components/settings/GrokAccountsSection.test.tsx b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx new file mode 100644 index 000000000..cd4185977 --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshGrokRateLimits: vi.fn() +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'grok-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshGrokRateLimits: mocks.refreshGrokRateLimits, + rateLimits: { grok: null } + }) +})) + +import { GrokAccountsSection } from './GrokAccountsSection' + +describe('GrokAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + teamId: null, + tokenFresh: false, + error: null + }) + mocks.refreshGrokRateLimits.mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { grokAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('explains the host-scoped Grok recovery flow without requiring a chat message', async () => { + render() + + expect( + await screen.findByText( + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index 474c4c1ee..8df194dbd 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -109,12 +109,12 @@ export function GrokAccountsSection(): React.JSX.Element {

{tokenFresh ? translate( - 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', - 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + 'auto.components.settings.GrokAccountsSection.b36fa2c908', + 'Signed in. Orca reads the Grok CLI session stored on disk.' ) : translate( - 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', - 'Session expired — run grok login in a terminal to refresh.' + 'auto.components.settings.GrokAccountsSection.f08c41de73', + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' )}

diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 87e12d660..ed80c7b9a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -135,18 +135,36 @@ describe('provider usage error copy', () => { ) }) - it('keeps the reworded Grok expired-token error classified as an auth failure (#8497)', () => { - // Why: the fix (grok-fetcher.ts) dropped the "run grok login" wording that - // used to trigger auth classification; this pins that the new copy still - // resolves to the softer refresh message instead of leaking the raw string. + it('shows the exact Grok CLI recovery flow for an expired refreshable session (#8497)', () => { const grok = provider({ provider: 'grok', - error: 'Grok access token expired — Grok CLI will refresh it on next use' + error: + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } }) - expect(getProviderUsageStatusLabel(grok)).toBe('Refresh failed') + expect(getProviderUsageStatusLabel(grok)).toBe('Run Grok to refresh') expect(getProviderUsageErrorMessage(grok)).toBe( - 'Grok usage could not be refreshed. Agent sessions may still be signed in.' + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + }) + + it('shows the exact Kimi CLI recovery flow for an expired read-only session', () => { + const kimi = provider({ + provider: 'kimi', + error: 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } + }) + + expect(getProviderUsageStatusLabel(kimi)).toBe('Run Kimi to refresh') + expect(getProviderUsageErrorMessage(kimi)).toBe( + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' ) }) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 0b0c3bc2e..39032ab5e 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -64,7 +64,25 @@ function isUsageAuthError(message: string | null): boolean { return Boolean(message && USAGE_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message))) } +function getDelegatedCliRefreshProvider( + p: ProviderRateLimits +): Extract | null { + if (p.usageMetadata?.failureKind !== 'delegated-refresh-required') { + return null + } + // Why: only these providers require a user-run CLI to rotate the read-only + // session Orca consumes; Claude handles the same failure kind in-app. + return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null +} + export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate('auto.components.status.bar.tooltip.e2c6a4f917', 'Run Grok to refresh') + } + if (delegatedCliProvider === 'kimi') { + return translate('auto.components.status.bar.tooltip.f90b3d7a16', 'Run Kimi to refresh') + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': @@ -107,6 +125,19 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (!p.error) { return fallback } + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate( + 'auto.components.status.bar.tooltip.d1b7f509ac', + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + } + if (delegatedCliProvider === 'kimi') { + return translate( + 'auto.components.status.bar.tooltip.a37e8c15d4', + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' + ) + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a0a4265cf..6596b371b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.", "c06c1d215d": "Claude usage could not be refreshed because the network request failed.", "cabdc2a9e0": "Claude sign-in credentials could not be read.", - "a7517cccb6": "Claude usage is unavailable right now." + "a7517cccb6": "Claude usage is unavailable right now.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH Host" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5fec0c3ef..2b568c764 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir iniciadas.", "c06c1d215d": "No se pudo actualizar el uso de Claude porque falló la solicitud de red.", "cabdc2a9e0": "No se pudieron leer las credenciales de inicio de sesión de Claude.", - "a7517cccb6": "El uso de Claude no está disponible ahora." + "a7517cccb6": "El uso de Claude no está disponible ahora.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "Host SSH" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a760df564..70aaea124 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude サインインを更新中です。エージェントセッションはまだサインイン済みの場合があります。", "c06c1d215d": "ネットワークリクエストに失敗したため、Claude 使用量を更新できませんでした。", "cabdc2a9e0": "Claude サインイン認証情報を読み取れませんでした。", - "a7517cccb6": "Claude 使用量は現在利用できません。" + "a7517cccb6": "Claude 使用量は現在利用できません。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce8f97cf3..98311b01d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.", "c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.", "cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.", - "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다." + "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dc56b80b9..7a439d1ef 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "正在刷新 Claude 登录。智能体会话可能仍处于登录状态。", "c06c1d215d": "由于网络请求失败,无法刷新 Claude 用量。", "cabdc2a9e0": "无法读取 Claude 登录凭据。", - "a7517cccb6": "Claude 用量目前不可用。" + "a7517cccb6": "Claude 用量目前不可用。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 主机" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用",