fix: 修复额度耗尽错误展示
问题:Token balance exhausted 被作为原始助手气泡展示,且额度确认可能跨会话复用。 修复:区分绝对余额与滚动额度,绑定当前会话失败 key,并补充真实消息结构与双会话回归测试。
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# Task: 诊断 Token balance exhausted 的错误呈现与请求行为
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260808-token-balance-7c2f
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: main
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore
|
||||||
|
- Base commit: 52626b332da5e0f3848223848432cff74caa5273
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 诊断 AI Gateway 返回 `token_balance_exhausted` 后模型请求失败、原始英文错误进入聊天记录,以及额度接口重复读取的问题。
|
||||||
|
- 修复当前会话实时额度错误的升级提示,并友好呈现已持久化的绝对余额错误。
|
||||||
|
- 为错误分类、真实 OpenCode transcript 结构及误判边界补充聚焦回归测试。
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- 不在客户端计算或伪造远端余额;实际可用性仍由 Works Square 账本决定。
|
||||||
|
- 只把带 `isError` 标记的 assistant 错误作为错误处理,正常回复中出现同样字样不得误判。
|
||||||
|
- 绝对余额耗尽可直接确认;5 小时/周滚动额度错误继续通过现有 token usage 契约核验。
|
||||||
|
- 弹窗只绑定当前选中会话和具体失败 key;历史记录不重新激活升级弹窗。
|
||||||
|
- 保持 Renderer 经 Host API/Main 边界访问后端,不新增 IPC 或直连。
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- 证实远端网关在 introspect 后以 `balance=0` 拒绝 authorize;本仓库不包含该 Python 网关或账本计算,客户端无法恢复真实额度。
|
||||||
|
- 证实本机 OpenCode 数据库中的三条失败均为无 parts 的 structured assistant error,原始文本此前被当作普通气泡渲染。
|
||||||
|
- 新增绝对 token-balance 判定,并仅对该类结构化错误显示中文友好文案。
|
||||||
|
- 当前会话的实时绝对余额错误直接打开升级提示,不再用滚动额度百分比错误地反证,也少发一次 token usage 请求。
|
||||||
|
- 历史错误仅做中文化,不会在充值后重进旧会话时再次弹窗;后台会话错误也不会绑定到当前会话。
|
||||||
|
- 额度确认和弹窗状态绑定具体 failure key,避免切换会话时沿用旧确认状态。
|
||||||
|
- 重复 token usage 请求的剩余来源是 Sidebar 的 stale 后立即刷新及 1.5 秒一致性复查;本次未扩大为全局请求去重重构。
|
||||||
|
- README 无需更新:产品边界和架构未变化,本次为既有错误呈现修正。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED:`pnpm exec vitest run tests/unit/opencode-chat-panel.test.tsx -t "shows a subscription upgrade dialog instead of the raw quota error"`,修复前 DOM 可见原始英文错误。
|
||||||
|
- GREEN:7 个相关单测文件共 259 项通过,覆盖 ChatPanel、错误分类、消息归一化、store、token usage、AI proxy 与 Sidebar。
|
||||||
|
- `pnpm run typecheck` 通过。
|
||||||
|
- 聚焦 ESLint 通过:4 个变更源码/测试文件无报错。
|
||||||
|
- `pnpm run build:vite` 通过;仅保留既有 chunk 大小提示。
|
||||||
|
- `pnpm test` 未全绿:与本次变更无文件交集的环境/基线失败可独立复现,包括系统缺少 `zip`、`.opencode/agent` 生成目录不存在,以及 `project-progress-sync` 的 3 个既有 watcher/调用次数断言失败。
|
||||||
|
- 未新增 Electron E2E:现有共享 fixture 无远端额度错误注入点;单测使用了本机 SQLite 中核对过的真实 structured message 形状。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Works Square 运维侧为对应账号充值,或检查 One API 用户/令牌映射与账本同步;否则模型请求仍会被远端拒绝。
|
||||||
|
- 后续可评估在 token usage 契约中暴露绝对余额维度,并集中去重 Sidebar 的即时/延迟刷新。
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: accepted architecture/domain documentation.
|
||||||
|
- Proposal: 明确区分“绝对 token balance”与“滚动 5 小时/周额度”;绝对余额错误不得被滚动百分比反证。
|
||||||
|
- Evidence: 网关日志、OpenCode SQLite structured error、错误分类与 ChatPanel 回归测试。
|
||||||
|
- Future impact: 后续新增额度来源或用量 UI 时复用同一语义,避免再次混淆。
|
||||||
|
- Human confirmation required: yes.
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
export type OpencodeErrorKind = 'quota_exhausted' | 'authentication_invalid';
|
export type OpencodeErrorKind = 'quota_exhausted' | 'authentication_invalid';
|
||||||
|
|
||||||
|
export function isOpencodeTokenBalanceExhausted(message: string | null | undefined): boolean {
|
||||||
|
const normalized = message?.trim().toLowerCase() ?? '';
|
||||||
|
return normalized.includes('token_balance_exhausted')
|
||||||
|
|| normalized.includes('token balance exhausted')
|
||||||
|
|| normalized.includes('balance exhausted');
|
||||||
|
}
|
||||||
|
|
||||||
export function getOpencodeErrorKind(message: string | null | undefined): OpencodeErrorKind | null {
|
export function getOpencodeErrorKind(message: string | null | undefined): OpencodeErrorKind | null {
|
||||||
const normalized = message?.trim().toLowerCase() ?? '';
|
const normalized = message?.trim().toLowerCase() ?? '';
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
if (
|
if (
|
||||||
normalized.includes('user quota is not enough')
|
isOpencodeTokenBalanceExhausted(normalized)
|
||||||
|
|| normalized.includes('user quota is not enough')
|
||||||
|| normalized.includes('quota is not enough')
|
|| normalized.includes('quota is not enough')
|
||||||
|| normalized.includes('quota not enough')
|
|| normalized.includes('quota not enough')
|
||||||
|| normalized.includes('insufficient quota')
|
|| normalized.includes('insufficient quota')
|
||||||
|| normalized.includes('quota exhausted')
|
|| normalized.includes('quota exhausted')
|
||||||
|| normalized.includes('quota exceeded')
|
|| normalized.includes('quota exceeded')
|
||||||
|| normalized.includes('token_balance_exhausted')
|
|
||||||
|| normalized.includes('token balance exhausted')
|
|
||||||
|| normalized.includes('balance exhausted')
|
|
||||||
|| normalized.includes('rolling 5-hour window')
|
|| normalized.includes('rolling 5-hour window')
|
||||||
|| normalized.includes('额度不足')
|
|| normalized.includes('额度不足')
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ import {
|
|||||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
||||||
normalizeImportedUserModelId,
|
normalizeImportedUserModelId,
|
||||||
} from '../../../shared/user-model-config';
|
} from '../../../shared/user-model-config';
|
||||||
import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind';
|
import {
|
||||||
|
getOpencodeErrorKind,
|
||||||
|
isOpencodeTokenBalanceExhausted,
|
||||||
|
} from '../../../shared/opencode-error-kind';
|
||||||
import {
|
import {
|
||||||
buildGameAssetReviewBatchMessage,
|
buildGameAssetReviewBatchMessage,
|
||||||
hasGameAssetReviewContent,
|
hasGameAssetReviewContent,
|
||||||
@@ -789,6 +792,16 @@ function isQuotaExhaustedRetryMessage(message: string | undefined): boolean {
|
|||||||
return getOpencodeErrorKind(message) === 'quota_exhausted';
|
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 {
|
function getUpstreamSaturatedRetryNotice(status: OpencodeSessionStatus | undefined): string | null {
|
||||||
return status?.type === 'retry' && isUpstreamSaturatedRetryMessage(status.message)
|
return status?.type === 'retry' && isUpstreamSaturatedRetryMessage(status.message)
|
||||||
? UPSTREAM_SATURATED_RETRY_NOTICE
|
? UPSTREAM_SATURATED_RETRY_NOTICE
|
||||||
@@ -822,6 +835,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
const sessions = useOpencodeStore((state) => state.sessions);
|
const sessions = useOpencodeStore((state) => state.sessions);
|
||||||
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
|
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
|
||||||
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
|
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
|
||||||
|
const selectedSessionRunError = useOpencodeStore((state) => (
|
||||||
|
selectedSessionId ? state.sessionRunStates[selectedSessionId]?.error ?? null : null
|
||||||
|
));
|
||||||
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
|
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
|
||||||
const sessionTranscript = useOpencodeStore((state) => (
|
const sessionTranscript = useOpencodeStore((state) => (
|
||||||
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
|
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
|
||||||
@@ -937,8 +953,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
const [responseWaitNow, setResponseWaitNow] = useState(() => Date.now());
|
const [responseWaitNow, setResponseWaitNow] = useState(() => Date.now());
|
||||||
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
||||||
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
|
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
|
||||||
const [quotaUpgradePromptOpen, setQuotaUpgradePromptOpen] = useState(false);
|
const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState<string | null>(null);
|
||||||
const [quotaConfirmation, setQuotaConfirmation] = useState<'idle' | 'checking' | 'confirmed' | 'dismissed'>('idle');
|
const [confirmedQuotaFailureKey, setConfirmedQuotaFailureKey] = useState<string | null>(null);
|
||||||
const handledAuthenticationFailureRef = useRef<string | null>(null);
|
const handledAuthenticationFailureRef = useRef<string | null>(null);
|
||||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||||
const [switchingModelRef, setSwitchingModelRef] = useState<string | null>(null);
|
const [switchingModelRef, setSwitchingModelRef] = useState<string | null>(null);
|
||||||
@@ -1074,9 +1090,16 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
&& !loading;
|
&& !loading;
|
||||||
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
|
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
|
||||||
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
|
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
|
||||||
const rawQuotaExhaustedActive = (errorKind === 'quota_exhausted' && Boolean(error))
|
const quotaExhaustedError = getOpencodeErrorKind(selectedSessionRunError) === 'quota_exhausted'
|
||||||
|| quotaExhaustedRetryActive;
|
? selectedSessionRunError
|
||||||
const quotaExhaustedErrorActive = rawQuotaExhaustedActive && quotaConfirmation === 'confirmed';
|
: 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'
|
const authenticationInvalidRetryActive = selectedSessionStatus?.type === 'retry'
|
||||||
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
|
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
|
||||||
const authenticationInvalidActive = (errorKind === 'authentication_invalid' && Boolean(error))
|
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'}`
|
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
|
||||||
: null;
|
: null;
|
||||||
const quotaFailureKey = rawQuotaExhaustedActive
|
const quotaFailureKey = rawQuotaExhaustedActive
|
||||||
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'quota_exhausted'}`
|
? `${selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
|
||||||
: null;
|
: 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)
|
? (quotaExhaustedErrorActive ? QUOTA_EXHAUSTED_MESSAGE : null)
|
||||||
: error;
|
: error;
|
||||||
const upstreamSaturatedRetryNotice = getUpstreamSaturatedRetryNotice(selectedSessionStatus);
|
const upstreamSaturatedRetryNotice = getUpstreamSaturatedRetryNotice(selectedSessionStatus);
|
||||||
@@ -1282,14 +1309,20 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quotaFailureKey) {
|
if (!quotaFailureKey) {
|
||||||
setQuotaConfirmation('idle');
|
setConfirmedQuotaFailureKey(null);
|
||||||
setQuotaUpgradePromptOpen(false);
|
setQuotaUpgradePromptKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenBalanceExhaustedActive) {
|
||||||
|
setConfirmedQuotaFailureKey(quotaFailureKey);
|
||||||
|
setQuotaUpgradePromptKey(quotaFailureKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setQuotaConfirmation('checking');
|
setConfirmedQuotaFailureKey(null);
|
||||||
setQuotaUpgradePromptOpen(false);
|
setQuotaUpgradePromptKey(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const token = await getValidAccessToken();
|
const token = await getValidAccessToken();
|
||||||
@@ -1297,19 +1330,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
const usage = await fetchWorksTokenUsage(token);
|
const usage = await fetchWorksTokenUsage(token);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const confirmed = isWorksTokenUsageExhausted(usage);
|
const confirmed = isWorksTokenUsageExhausted(usage);
|
||||||
setQuotaConfirmation(confirmed ? 'confirmed' : 'dismissed');
|
setConfirmedQuotaFailureKey(confirmed ? quotaFailureKey : null);
|
||||||
setQuotaUpgradePromptOpen(confirmed);
|
setQuotaUpgradePromptKey(confirmed ? quotaFailureKey : null);
|
||||||
} catch {
|
} catch {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setQuotaConfirmation('confirmed');
|
setConfirmedQuotaFailureKey(quotaFailureKey);
|
||||||
setQuotaUpgradePromptOpen(true);
|
setQuotaUpgradePromptKey(quotaFailureKey);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [getValidAccessToken, quotaFailureKey]);
|
}, [getValidAccessToken, quotaFailureKey, tokenBalanceExhaustedActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticationFailureKey) {
|
if (!authenticationFailureKey) {
|
||||||
@@ -2679,13 +2712,16 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
messageExecutionRun
|
messageExecutionRun
|
||||||
&& message.role === 'assistant',
|
&& message.role === 'assistant',
|
||||||
);
|
);
|
||||||
const assistantTextOverride = getAssistantTextOverride(
|
const assistantErrorMessage = getAssistantErrorMessage(message);
|
||||||
message,
|
const assistantTextOverride = isOpencodeTokenBalanceExhausted(assistantErrorMessage)
|
||||||
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
|
? QUOTA_EXHAUSTED_MESSAGE
|
||||||
getNearestUserPrompt(visibleTranscriptMessages, index),
|
: getAssistantTextOverride(
|
||||||
projectAssistantTextWithNativeParts(message, sessionTranscript)
|
message,
|
||||||
?? projectAssistantTextForCodex(message),
|
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
|
||||||
);
|
getNearestUserPrompt(visibleTranscriptMessages, index),
|
||||||
|
projectAssistantTextWithNativeParts(message, sessionTranscript)
|
||||||
|
?? projectAssistantTextForCodex(message),
|
||||||
|
);
|
||||||
const isGameAssetSelectionRound = hasGameAssetReviewContent(message);
|
const isGameAssetSelectionRound = hasGameAssetReviewContent(message);
|
||||||
const gameAssetReviewInvocation = parseGameAssetReviewInvocation(message);
|
const gameAssetReviewInvocation = parseGameAssetReviewInvocation(message);
|
||||||
const assetReviewInvocation = gameAssetReviewInvocation ?? (isGameAssetSelectionRound
|
const assetReviewInvocation = gameAssetReviewInvocation ?? (isGameAssetSelectionRound
|
||||||
@@ -3234,7 +3270,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setQuotaUpgradePromptOpen(false)}
|
onClick={() => setQuotaUpgradePromptKey(null)}
|
||||||
>
|
>
|
||||||
稍后再说
|
稍后再说
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5156,6 +5156,7 @@ describe('OpencodeChatPanel', () => {
|
|||||||
projects: [activeProject], activeProject,
|
projects: [activeProject], activeProject,
|
||||||
sessions: [{ id: 'ses_1', title: 'Quota check' }], selectedSessionId: 'ses_1',
|
sessions: [{ id: 'ses_1', title: 'Quota check' }], selectedSessionId: 'ses_1',
|
||||||
sessionStatuses: { ses_1: { type: 'retry', message: 'Token quota exhausted for rolling 5-hour window' } },
|
sessionStatuses: { ses_1: { type: 'retry', message: 'Token quota exhausted for rolling 5-hour window' } },
|
||||||
|
sessionMessages: [{ id: 'msg_docs', role: 'assistant', content: '文档示例:Token balance exhausted' }],
|
||||||
error: 'Token quota exhausted for rolling 5-hour window', errorKind: 'quota_exhausted',
|
error: 'Token quota exhausted for rolling 5-hour window', errorKind: 'quota_exhausted',
|
||||||
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
||||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||||
@@ -5177,6 +5178,7 @@ describe('OpencodeChatPanel', () => {
|
|||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
));
|
));
|
||||||
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('文档示例:Token balance exhausted')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears login state for a generic gateway authorization failure', async () => {
|
it('clears login state for a generic gateway authorization failure', async () => {
|
||||||
@@ -5205,7 +5207,7 @@ describe('OpencodeChatPanel', () => {
|
|||||||
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a subscription upgrade dialog instead of the raw quota error', async () => {
|
it('localizes persisted token-balance errors and only opens the upgrade dialog for a live failure', async () => {
|
||||||
const activeProject = {
|
const activeProject = {
|
||||||
id: 'prj_1',
|
id: 'prj_1',
|
||||||
path: 'D:/repo/packages/ui',
|
path: 'D:/repo/packages/ui',
|
||||||
@@ -5226,12 +5228,11 @@ describe('OpencodeChatPanel', () => {
|
|||||||
activeProject,
|
activeProject,
|
||||||
sessions: [
|
sessions: [
|
||||||
{ id: 'ses_1', title: 'Investigate quota handling', updatedAt: '2026-05-12T08:30:00.000Z' },
|
{ id: 'ses_1', title: 'Investigate quota handling', updatedAt: '2026-05-12T08:30:00.000Z' },
|
||||||
|
{ id: 'ses_2', title: 'Other session', updatedAt: '2026-05-12T08:31:00.000Z' },
|
||||||
],
|
],
|
||||||
selectedSessionId: 'ses_1',
|
selectedSessionId: 'ses_1',
|
||||||
sessionStatuses: { ses_1: { type: 'retry', message: 'user quota is not enough' } },
|
sessionStatuses: {},
|
||||||
sessionMessages: [{ id: 'msg_0', role: 'assistant', content: 'Ready when you are.' }],
|
sessionMessages: [],
|
||||||
error: 'user quota is not enough (request id: 2026070514232177672556811718704)',
|
|
||||||
errorKind: 'quota_exhausted',
|
|
||||||
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
||||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||||
if (path === '/api/opencode/status') {
|
if (path === '/api/opencode/status') {
|
||||||
@@ -5254,17 +5255,39 @@ describe('OpencodeChatPanel', () => {
|
|||||||
return {
|
return {
|
||||||
sessions: [
|
sessions: [
|
||||||
{ id: 'ses_1', title: 'Investigate quota handling', updatedAt: '2026-05-12T08:30:00.000Z' },
|
{ id: 'ses_1', title: 'Investigate quota handling', updatedAt: '2026-05-12T08:30:00.000Z' },
|
||||||
|
{ id: 'ses_2', title: 'Other session', updatedAt: '2026-05-12T08:31:00.000Z' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (path === '/api/opencode/sessions/status') {
|
if (path === '/api/opencode/sessions/status') {
|
||||||
return { statuses: { ses_1: { type: 'retry', message: 'user quota is not enough' } } };
|
return { statuses: {} };
|
||||||
}
|
}
|
||||||
if (path === '/api/works/billing/token-usage') {
|
if (path === '/api/works/billing/token-usage') {
|
||||||
return { success: true, usage: { five_hour_remaining_percent: 0, weekly_remaining_percent: 80 } };
|
return { success: true, usage: { five_hour_remaining_percent: 75, weekly_remaining_percent: 80 } };
|
||||||
}
|
}
|
||||||
if (path === '/api/opencode/sessions/ses_1/messages') {
|
if (path === '/api/opencode/sessions/ses_1/messages') {
|
||||||
return { messages: [{ id: 'msg_0', role: 'assistant', content: 'Ready when you are.' }] };
|
return {
|
||||||
|
messages: [{
|
||||||
|
info: {
|
||||||
|
id: 'msg_0',
|
||||||
|
sessionID: 'ses_1',
|
||||||
|
role: 'assistant',
|
||||||
|
time: { created: 1_786_186_022_467, completed: 1_786_186_022_559 },
|
||||||
|
error: {
|
||||||
|
name: 'APIError',
|
||||||
|
data: {
|
||||||
|
message: 'Token balance exhausted (request id: 2026080810470193210007741833395)',
|
||||||
|
statusCode: 403,
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/api/opencode/sessions/ses_2/messages') {
|
||||||
|
return { messages: [] };
|
||||||
}
|
}
|
||||||
if (path === '/api/opencode/files/status') {
|
if (path === '/api/opencode/files/status') {
|
||||||
return { files: [] };
|
return { files: [] };
|
||||||
@@ -5272,15 +5295,93 @@ describe('OpencodeChatPanel', () => {
|
|||||||
throw new Error(`Unexpected path ${path}`);
|
throw new Error(`Unexpected path ${path}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<OpencodeChatPanel variant="main" />);
|
const { unmount } = render(<OpencodeChatPanel variant="main" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/sessions/ses_1/messages')).toBe(true);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('Token balance exhausted (request id: 2026080810470193210007741833395)')).not.toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('当前账号额度不足,请升级订阅后继续使用 Makelore。')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
|
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/billing/token-usage')).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useOpencodeStore.setState({
|
||||||
|
error: 'Token balance exhausted (request id: 2026080810470193210007741833395)',
|
||||||
|
errorKind: 'quota_exhausted',
|
||||||
|
sessionRunStates: {
|
||||||
|
ses_1: {
|
||||||
|
phase: 'idle',
|
||||||
|
runId: 1,
|
||||||
|
promptId: 'msg_user',
|
||||||
|
queue: [],
|
||||||
|
terminalReason: 'failed',
|
||||||
|
error: 'Token balance exhausted (request id: 2026080810470193210007741833395)',
|
||||||
|
suppressNextAbortError: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: '额度不足' });
|
const dialog = await screen.findByRole('dialog', { name: '额度不足' });
|
||||||
expect(dialog).toHaveTextContent('当前账号额度不足,请升级订阅后继续使用 Makelore。');
|
expect(dialog).toHaveTextContent('当前账号额度不足,请升级订阅后继续使用 Makelore。');
|
||||||
expect(screen.queryByText('user quota is not enough (request id: 2026070514232177672556811718704)')).not.toBeInTheDocument();
|
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/billing/token-usage')).toBe(false);
|
||||||
|
|
||||||
const upgradeButton = within(dialog).getByRole('button', { name: '升级订阅' });
|
const upgradeButton = within(dialog).getByRole('button', { name: '升级订阅' });
|
||||||
expect(upgradeButton).toBeEnabled();
|
expect(upgradeButton).toBeEnabled();
|
||||||
expect(upgradeButton).toHaveAttribute('data-upgrade-url', 'https://square.nianxx.cn/#profile');
|
expect(upgradeButton).toHaveAttribute('data-upgrade-url', 'https://square.nianxx.cn/#profile');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useOpencodeStore.setState({
|
||||||
|
error: 'Token quota exhausted for rolling 5-hour window',
|
||||||
|
errorKind: 'quota_exhausted',
|
||||||
|
sessionRunStates: {
|
||||||
|
ses_1: {
|
||||||
|
phase: 'idle',
|
||||||
|
runId: 2,
|
||||||
|
promptId: 'msg_user_2',
|
||||||
|
queue: [],
|
||||||
|
terminalReason: 'failed',
|
||||||
|
error: 'Token quota exhausted for rolling 5-hour window',
|
||||||
|
suppressNextAbortError: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/billing/token-usage')).toBe(true);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
act(() => {
|
||||||
|
useOpencodeStore.setState({
|
||||||
|
selectedSessionId: 'ses_2',
|
||||||
|
sessionMessages: [],
|
||||||
|
error: 'Token balance exhausted (request id: background-request)',
|
||||||
|
errorKind: 'quota_exhausted',
|
||||||
|
sessionRunStates: {
|
||||||
|
ses_1: {
|
||||||
|
phase: 'idle',
|
||||||
|
runId: 3,
|
||||||
|
promptId: 'msg_user_3',
|
||||||
|
queue: [],
|
||||||
|
terminalReason: 'failed',
|
||||||
|
error: 'Token balance exhausted (request id: background-request)',
|
||||||
|
suppressNextAbortError: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
render(<OpencodeChatPanel variant="main" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/sessions/ses_2/messages')).toBe(true);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('quota-upgrade-dialog')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Token balance exhausted (request id: background-request)')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for image processing before posting the selected attachment', async () => {
|
it('waits for image processing before posting the selected attachment', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getOpencodeErrorKind } from '../../shared/opencode-error-kind';
|
import {
|
||||||
|
getOpencodeErrorKind,
|
||||||
|
isOpencodeTokenBalanceExhausted,
|
||||||
|
} from '../../shared/opencode-error-kind';
|
||||||
|
|
||||||
describe('getOpencodeErrorKind', () => {
|
describe('getOpencodeErrorKind', () => {
|
||||||
it('classifies a generic Works Square gateway authorization failure as invalid authentication', () => {
|
it('classifies a generic Works Square gateway authorization failure as invalid authentication', () => {
|
||||||
@@ -14,4 +17,20 @@ describe('getOpencodeErrorKind', () => {
|
|||||||
])('classifies explicit quota evidence as exhausted: %s', (message) => {
|
])('classifies explicit quota evidence as exhausted: %s', (message) => {
|
||||||
expect(getOpencodeErrorKind(message)).toBe('quota_exhausted');
|
expect(getOpencodeErrorKind(message)).toBe('quota_exhausted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'token_balance_exhausted',
|
||||||
|
'Token balance exhausted (request id: req-1)',
|
||||||
|
'works_square_gateway_authorize_failed: balance exhausted',
|
||||||
|
])('identifies authoritative token-balance exhaustion: %s', (message) => {
|
||||||
|
expect(isOpencodeTokenBalanceExhausted(message)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'Token quota exhausted for rolling 5-hour window',
|
||||||
|
'user quota is not enough',
|
||||||
|
'works_square_gateway_authorize_failed',
|
||||||
|
])('does not mistake other failures for token-balance exhaustion: %s', (message) => {
|
||||||
|
expect(isOpencodeTokenBalanceExhausted(message)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user