fix: 修复额度耗尽错误展示
问题:Token balance exhausted 被作为原始助手气泡展示,且额度确认可能跨会话复用。 修复:区分绝对余额与滚动额度,绑定当前会话失败 key,并补充真实消息结构与双会话回归测试。
This commit is contained in:
@@ -5156,6 +5156,7 @@ describe('OpencodeChatPanel', () => {
|
||||
projects: [activeProject], activeProject,
|
||||
sessions: [{ id: 'ses_1', title: 'Quota check' }], selectedSessionId: 'ses_1',
|
||||
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',
|
||||
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
@@ -5177,6 +5178,7 @@ describe('OpencodeChatPanel', () => {
|
||||
expect.any(Object),
|
||||
));
|
||||
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 () => {
|
||||
@@ -5205,7 +5207,7 @@ describe('OpencodeChatPanel', () => {
|
||||
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 = {
|
||||
id: 'prj_1',
|
||||
path: 'D:/repo/packages/ui',
|
||||
@@ -5226,12 +5228,11 @@ describe('OpencodeChatPanel', () => {
|
||||
activeProject,
|
||||
sessions: [
|
||||
{ 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',
|
||||
sessionStatuses: { ses_1: { type: 'retry', message: 'user quota is not enough' } },
|
||||
sessionMessages: [{ id: 'msg_0', role: 'assistant', content: 'Ready when you are.' }],
|
||||
error: 'user quota is not enough (request id: 2026070514232177672556811718704)',
|
||||
errorKind: 'quota_exhausted',
|
||||
sessionStatuses: {},
|
||||
sessionMessages: [],
|
||||
} as Partial<ReturnType<typeof useOpencodeStore.getState>>);
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/opencode/status') {
|
||||
@@ -5254,17 +5255,39 @@ describe('OpencodeChatPanel', () => {
|
||||
return {
|
||||
sessions: [
|
||||
{ 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') {
|
||||
return { statuses: { ses_1: { type: 'retry', message: 'user quota is not enough' } } };
|
||||
return { statuses: {} };
|
||||
}
|
||||
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') {
|
||||
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') {
|
||||
return { files: [] };
|
||||
@@ -5272,15 +5295,93 @@ describe('OpencodeChatPanel', () => {
|
||||
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: '额度不足' });
|
||||
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: '升级订阅' });
|
||||
expect(upgradeButton).toBeEnabled();
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getOpencodeErrorKind } from '../../shared/opencode-error-kind';
|
||||
import {
|
||||
getOpencodeErrorKind,
|
||||
isOpencodeTokenBalanceExhausted,
|
||||
} from '../../shared/opencode-error-kind';
|
||||
|
||||
describe('getOpencodeErrorKind', () => {
|
||||
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) => {
|
||||
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