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

@@ -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 () => {

View File

@@ -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);
});
});