Files
orca/src/main/runtime/runtime-client-settings.ts
T
Neil c3a70082c6 Fix MiniMax credential-expiry reporting, region sync, and refresh (#19250)
* Fix MiniMax credential-expiry reporting, region sync, and refresh

Three defects from #14929:

1. The usage endpoint answers an expired cookie or key with HTTP 200 and
   base_resp.status_code 1004, never 401/403 (confirmed against both regional
   hosts). The stale-token branch was therefore unreachable, so expired
   credentials surfaced as 'usage-unavailable' with the raw upstream string,
   and stale policy kept showing old numbers as if the failure were transient.
   Classify 1004 as an expired credential.

2. minimaxEndpoint reached the SettingsUpdate schema and the web store but was
   never projected by RuntimeClientSettingsController.get(), so a paired client
   fell back to 'overseas' regardless of the host's region and rendered the
   wrong console link. Add it to the projection and the store contract.

3. Changing the region persisted without refreshing usage, leaving the previous
   host's snapshot in the status bar until the next poll. Invalidate and refetch
   when the endpoint, group id, or model list changes.

The RPC-level tests mock the controller, so the projection had no real
coverage; the new test fails against the pre-fix projection.

* Localize the MiniMax credential-expiry copy

Classifying 1004 as stale-token made the status bar show the raw English
error verbatim: the new wording matches none of USAGE_AUTH_ERROR_PATTERNS,
whereas the old upstream text ('...log in again') matched and was replaced
with localized copy. That traded a localized-but-misleading message for an
actionable English-only one, which is the wrong trade for the CN users this
work targets.

Tag the error with credentialSource so the renderer can pick the right
localized string per credential kind, and add the three catalog entries.
2026-09-07 01:22:08 -07:00

226 lines
9.0 KiB
TypeScript

import { getAppEnvironment } from '../../shared/app-environment'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults'
import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import type { RuntimeStore } from './runtime-store-contract'
export type RuntimeClientSettings = Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'agentStatusHooksEnabled'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'experimentalNativeChat'
| 'openAgentTabsInChatByDefault'
| 'experimentalStructuredNativeChat'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'prBotAuthorOverrides'
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
| 'agentSkillSharingEnabled'
> & {
hostSettingOverrides: RuntimeHostDisplayLabelOverrides
}
/** Safe paired projection: host labels only; filesystem defaults stay host-private. */
export type RuntimeHostDisplayLabelOverrides = Partial<
Record<ExecutionHostId, { displayLabel: string }>
>
export type RuntimeClientSettingsUpdate = Pick<
Partial<GlobalSettings>,
| 'agentStatusHooksEnabled'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
| 'prBotAuthorOverrides'
| 'worktreeVisibilityDefaults'
>
export class RuntimeClientSettingsController {
private reconciliationGeneration = 0
private reconciliationTail: Promise<void> = Promise.resolve()
constructor(
private readonly store: RuntimeStore | null,
private readonly notifyReposChanged: (() => void) | undefined = undefined
) {}
get(): RuntimeClientSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
const settings = this.store.getSettings()
return {
defaultTuiAgent: settings.defaultTuiAgent ?? null,
disabledTuiAgents: settings.disabledTuiAgents ?? [],
agentCmdOverrides: settings.agentCmdOverrides ?? {},
agentDefaultArgs: settings.agentDefaultArgs ?? {},
agentDefaultEnv: settings.agentDefaultEnv ?? {},
agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false,
defaultTaskSource: settings.defaultTaskSource ?? 'github',
defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues',
visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS],
defaultRepoSelection: settings.defaultRepoSelection ?? null,
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
// The three that decide whether a new agent tab -- and so an orchestration worker -- is a
// structured chat session rather than a terminal agent.
experimentalNativeChat: settings.experimentalNativeChat === true,
openAgentTabsInChatByDefault: settings.openAgentTabsInChatByDefault === true,
experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true,
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
hostSettingOverrides: Object.fromEntries(
[
...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides })
].map(([hostId, displayLabel]) => [hostId, { displayLabel }])
) as RuntimeHostDisplayLabelOverrides
}
}
async update(updates: RuntimeClientSettingsUpdate): Promise<RuntimeClientSettings> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const beforeSettings = this.store.getSettings()
const before = beforeSettings.agentStatusHooksEnabled !== false
this.store.updateSettings(updates, { notifyListeners: true })
const settings = this.store.getSettings()
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged?.()
}
if (
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents))
) {
await this.reconcileManagedAgentHooks()
}
return this.get()
}
getTerminalQuickCommands(): TerminalQuickCommand[] {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return this.store.getSettings().terminalQuickCommands ?? []
}
updateTerminalQuickCommands(mutation: TerminalQuickCommandMutation): TerminalQuickCommand[] {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.getTerminalQuickCommands()
if (
mutation.type === 'upsert' &&
!current.some((command) => command.id === mutation.command.id) &&
current.length >= MAX_QUICK_COMMANDS
) {
throw new Error('Quick command limit reached')
}
const next = applyTerminalQuickCommandMutation(current, mutation)
this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true })
return this.getTerminalQuickCommands()
}
updatePRBotAuthorOverride(args: { author: string; isBot: boolean }): RuntimeClientSettings {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.store.getSettings().prBotAuthorOverrides
this.store.updateSettings(
{ prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) },
{ notifyListeners: true }
)
return this.get()
}
updateNativeChatSessionOptions(mutation: NativeChatSessionOptionSettingsMutation): void {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const next = applyNativeChatSessionOptionSettingsMutation(
this.store.getSettings().nativeChatSessionOptions,
mutation
)
if (next) {
this.store.updateSettings({ nativeChatSessionOptions: next }, { notifyListeners: true })
}
}
private reconcileManagedAgentHooks(): Promise<void> {
const generation = ++this.reconciliationGeneration
const reconciliation = this.reconciliationTail.then(async () => {
if (generation !== this.reconciliationGeneration) {
return
}
const settings = this.store?.getSettings()
if (!settings) {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
return (
current !== undefined &&
current.agentStatusHooksEnabled !== false &&
!current.disabledTuiAgents?.includes(agent)
)
}
})
})
this.reconciliationTail = reconciliation.catch(() => {})
return reconciliation
}
}