fix: 修复额度耗尽错误展示

问题:Token balance exhausted 被作为原始助手气泡展示,且额度确认可能跨会话复用。

修复:区分绝对余额与滚动额度,绑定当前会话失败 key,并补充真实消息结构与双会话回归测试。
This commit is contained in:
2026-08-08 19:34:31 +08:00
parent 52626b332d
commit d092132d86
5 changed files with 260 additions and 40 deletions

View File

@@ -92,7 +92,10 @@ import {
NIANCODE_USER_MODEL_ACCOUNT_ID,
normalizeImportedUserModelId,
} from '../../../shared/user-model-config';
import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind';
import {
getOpencodeErrorKind,
isOpencodeTokenBalanceExhausted,
} from '../../../shared/opencode-error-kind';
import {
buildGameAssetReviewBatchMessage,
hasGameAssetReviewContent,
@@ -789,6 +792,16 @@ function isQuotaExhaustedRetryMessage(message: string | undefined): boolean {
return getOpencodeErrorKind(message) === 'quota_exhausted';
}
function getAssistantErrorMessage(message: RawMessage | undefined): string | null {
if (message?.role !== 'assistant' || !message.isError) return null;
const explicitMessage = typeof message.errorMessage === 'string' && message.errorMessage.trim()
? message.errorMessage.trim()
: typeof message.error_message === 'string' && message.error_message.trim()
? message.error_message.trim()
: null;
return explicitMessage ?? (extractText(message).trim() || null);
}
function getUpstreamSaturatedRetryNotice(status: OpencodeSessionStatus | undefined): string | null {
return status?.type === 'retry' && isUpstreamSaturatedRetryMessage(status.message)
? UPSTREAM_SATURATED_RETRY_NOTICE
@@ -822,6 +835,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
const sessions = useOpencodeStore((state) => state.sessions);
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
const selectedSessionRunError = useOpencodeStore((state) => (
selectedSessionId ? state.sessionRunStates[selectedSessionId]?.error ?? null : null
));
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
const sessionTranscript = useOpencodeStore((state) => (
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
@@ -937,8 +953,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
const [responseWaitNow, setResponseWaitNow] = useState(() => Date.now());
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
const [quotaUpgradePromptOpen, setQuotaUpgradePromptOpen] = useState(false);
const [quotaConfirmation, setQuotaConfirmation] = useState<'idle' | 'checking' | 'confirmed' | 'dismissed'>('idle');
const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState<string | null>(null);
const [confirmedQuotaFailureKey, setConfirmedQuotaFailureKey] = useState<string | null>(null);
const handledAuthenticationFailureRef = useRef<string | null>(null);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [switchingModelRef, setSwitchingModelRef] = useState<string | null>(null);
@@ -1074,9 +1090,16 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
&& !loading;
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
const rawQuotaExhaustedActive = (errorKind === 'quota_exhausted' && Boolean(error))
|| quotaExhaustedRetryActive;
const quotaExhaustedErrorActive = rawQuotaExhaustedActive && quotaConfirmation === 'confirmed';
const quotaExhaustedError = getOpencodeErrorKind(selectedSessionRunError) === 'quota_exhausted'
? selectedSessionRunError
: null;
const quotaExhaustedRetryError = quotaExhaustedRetryActive
? selectedSessionStatus.message ?? 'quota_exhausted'
: null;
const activeQuotaExhaustedError = quotaExhaustedError
?? quotaExhaustedRetryError;
const rawQuotaExhaustedActive = Boolean(activeQuotaExhaustedError);
const tokenBalanceExhaustedActive = isOpencodeTokenBalanceExhausted(activeQuotaExhaustedError);
const authenticationInvalidRetryActive = selectedSessionStatus?.type === 'retry'
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
const authenticationInvalidActive = (errorKind === 'authentication_invalid' && Boolean(error))
@@ -1085,9 +1108,13 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
: null;
const quotaFailureKey = rawQuotaExhaustedActive
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'quota_exhausted'}`
? `${selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
: null;
const displayError = rawQuotaExhaustedActive
const quotaExhaustedErrorActive = quotaFailureKey !== null
&& confirmedQuotaFailureKey === quotaFailureKey;
const quotaUpgradePromptOpen = quotaFailureKey !== null
&& quotaUpgradePromptKey === quotaFailureKey;
const displayError = errorKind === 'quota_exhausted'
? (quotaExhaustedErrorActive ? QUOTA_EXHAUSTED_MESSAGE : null)
: error;
const upstreamSaturatedRetryNotice = getUpstreamSaturatedRetryNotice(selectedSessionStatus);
@@ -1282,14 +1309,20 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
useEffect(() => {
if (!quotaFailureKey) {
setQuotaConfirmation('idle');
setQuotaUpgradePromptOpen(false);
setConfirmedQuotaFailureKey(null);
setQuotaUpgradePromptKey(null);
return;
}
if (tokenBalanceExhaustedActive) {
setConfirmedQuotaFailureKey(quotaFailureKey);
setQuotaUpgradePromptKey(quotaFailureKey);
return;
}
let cancelled = false;
setQuotaConfirmation('checking');
setQuotaUpgradePromptOpen(false);
setConfirmedQuotaFailureKey(null);
setQuotaUpgradePromptKey(null);
void (async () => {
try {
const token = await getValidAccessToken();
@@ -1297,19 +1330,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
const usage = await fetchWorksTokenUsage(token);
if (cancelled) return;
const confirmed = isWorksTokenUsageExhausted(usage);
setQuotaConfirmation(confirmed ? 'confirmed' : 'dismissed');
setQuotaUpgradePromptOpen(confirmed);
setConfirmedQuotaFailureKey(confirmed ? quotaFailureKey : null);
setQuotaUpgradePromptKey(confirmed ? quotaFailureKey : null);
} catch {
if (cancelled) return;
setQuotaConfirmation('confirmed');
setQuotaUpgradePromptOpen(true);
setConfirmedQuotaFailureKey(quotaFailureKey);
setQuotaUpgradePromptKey(quotaFailureKey);
}
})();
return () => {
cancelled = true;
};
}, [getValidAccessToken, quotaFailureKey]);
}, [getValidAccessToken, quotaFailureKey, tokenBalanceExhaustedActive]);
useEffect(() => {
if (!authenticationFailureKey) {
@@ -2679,13 +2712,16 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
messageExecutionRun
&& message.role === 'assistant',
);
const assistantTextOverride = getAssistantTextOverride(
message,
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
getNearestUserPrompt(visibleTranscriptMessages, index),
projectAssistantTextWithNativeParts(message, sessionTranscript)
?? projectAssistantTextForCodex(message),
);
const assistantErrorMessage = getAssistantErrorMessage(message);
const assistantTextOverride = isOpencodeTokenBalanceExhausted(assistantErrorMessage)
? QUOTA_EXHAUSTED_MESSAGE
: getAssistantTextOverride(
message,
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
getNearestUserPrompt(visibleTranscriptMessages, index),
projectAssistantTextWithNativeParts(message, sessionTranscript)
?? projectAssistantTextForCodex(message),
);
const isGameAssetSelectionRound = hasGameAssetReviewContent(message);
const gameAssetReviewInvocation = parseGameAssetReviewInvocation(message);
const assetReviewInvocation = gameAssetReviewInvocation ?? (isGameAssetSelectionRound
@@ -3234,7 +3270,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
<Button
type="button"
variant="ghost"
onClick={() => setQuotaUpgradePromptOpen(false)}
onClick={() => setQuotaUpgradePromptKey(null)}
>
</Button>