diff --git a/.project-docs/30-worklog/tasks/20260808-token-balance-7c2f.md b/.project-docs/30-worklog/tasks/20260808-token-balance-7c2f.md new file mode 100644 index 0000000..998b6b2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260808-token-balance-7c2f.md @@ -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. diff --git a/shared/opencode-error-kind.ts b/shared/opencode-error-kind.ts index 958fced..0f38338 100644 --- a/shared/opencode-error-kind.ts +++ b/shared/opencode-error-kind.ts @@ -1,18 +1,23 @@ 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 { const normalized = message?.trim().toLowerCase() ?? ''; if (!normalized) return null; 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 not enough') || normalized.includes('insufficient quota') || normalized.includes('quota exhausted') || 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('额度不足') ) { diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index ba144a3..78d1cd8 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -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(null); const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false); - const [quotaUpgradePromptOpen, setQuotaUpgradePromptOpen] = useState(false); - const [quotaConfirmation, setQuotaConfirmation] = useState<'idle' | 'checking' | 'confirmed' | 'dismissed'>('idle'); + const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState(null); + const [confirmedQuotaFailureKey, setConfirmedQuotaFailureKey] = useState(null); const handledAuthenticationFailureRef = useRef(null); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [switchingModelRef, setSwitchingModelRef] = useState(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 diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 7ed049d..93a1e80 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -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>); 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>); 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(); + const { unmount } = render(); + + 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(); + + 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 () => { diff --git a/tests/unit/opencode-error-kind.test.ts b/tests/unit/opencode-error-kind.test.ts index 2d628b5..447ac7a 100644 --- a/tests/unit/opencode-error-kind.test.ts +++ b/tests/unit/opencode-error-kind.test.ts @@ -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); + }); });