From d523b72cf86a54c596d60411a59e491142f8d624 Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 11:46:04 +0800 Subject: [PATCH] fix: restore coding history and quota feedback --- ...60831-fix-history-quota-errors-6c2a91e4.md | 104 ++++++++++++++++++ electron/api/routes/ai-proxy.ts | 3 +- electron/coding-runtime/pi/event-projector.ts | 17 +-- .../coding-runtime/pi/provider-failure.ts | 25 +++++ .../coding-runtime/pi/session-projector.ts | 49 +++++---- shared/ai-gateway-error-kind.ts | 4 +- shared/coding-conversation-contracts.ts | 1 + shared/coding-conversation-reducer.ts | 1 + src/pages/Chat/CodingConversationTimeline.tsx | 47 +++++++- tests/e2e/pi-coding-first-chat.spec.ts | 67 ++++++++++- tests/unit/ai-proxy-routes.test.ts | 34 ++++++ .../coding-conversation-timeline.test.tsx | 91 ++++++++++++++- tests/unit/pi-event-projector.test.ts | 34 ++++++ tests/unit/pi-session-projector.test.ts | 52 ++++++++- 14 files changed, 480 insertions(+), 49 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md create mode 100644 electron/coding-runtime/pi/provider-failure.ts diff --git a/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md b/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md new file mode 100644 index 0000000..745a9c0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md @@ -0,0 +1,104 @@ +# Task: Fix Coding history loading and quota errors + +## Identity + +- Task ID: 20260831-fix-history-quota-errors-6c2a91e4 +- Mode: Feature +- Branch: codex/20260831-fix-history-quota-errors-6c2a91e4-fix-history-quota-errors-6c2a91e4 +- Worktree: /Users/inmanx/Documents/makelore-fix-history-quota-errors-6c2a91e4 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Restore access to the full persisted active-branch history of Makelore Code + conversations after Pi compaction, while keeping history display bounded in + the Renderer and preserving the user's scroll anchor when older content is + prepended. +- Recognize the observed upstream `403 token_point_balance_exhausted` contract + as a non-retryable Token Point quota failure and show actionable, safe + Chinese feedback instead of the generic `处理失败` summary. +- Add focused Main/projector/Renderer regression coverage and run the required + typecheck, lint, and production build verification. + +## Intent And Constraints + +- Preserve Pi `0.84.2` as the sole runtime and keep Pi session/wire shapes + inside Electron Main. Renderer continues to consume product-neutral + Snapshot/Patch data through the Host API. +- Full visible history must not change the compacted model context or expose + the private compaction summary. Accepted/uncertain mutations remain + non-replayed. +- Quota classification must be narrow enough not to reinterpret unrelated + `403`, `429`, or Provider failures, and raw upstream text must not leak into + Renderer state. +- The ready-for-integration progress-message task overlaps the timeline source + and test files but has no semantic conflict; keep changes localized to the + history-window and scrolling behavior. + +## Outcome + +- Pi session hydration now projects the complete active branch in chronological + order instead of dropping entries before the latest compaction boundary. + Compaction summaries remain private, and multiple durable compaction markers + reconcile against live IDs from newest to newest. +- The Renderer still mounts only the latest 120 nodes initially, automatically + prepends earlier nodes in 100-node steps near the top, and offsets + `scrollTop` by the added height so the current reading position stays fixed. +- The exact `403 token_point_balance_exhausted` / `词元点数余额不足` response is + classified as quota exhaustion, forwarded as non-retryable HTTP 402, and + projected as `CODING_PROVIDER_QUOTA_EXHAUSTED` with the safe message + `词元点数余额不足,请充值后重试。`. +- Live failures and persisted Pi assistant failures share the same redacting + classifier. Reopened conversations restore only the safe Provider notice; + raw upstream request IDs are not copied into the public Snapshot. +- The collapsed process summary displays the actionable public failure message + for both live runs and reopened persisted failures instead of generic + `处理失败`. + +## Verification + +- `pnpm exec vitest run tests/unit/pi-session-projector.test.ts tests/unit/coding-conversation-timeline.test.tsx tests/unit/ai-proxy-routes.test.ts tests/unit/pi-event-projector.test.ts` + - Passed: 4 files, 45 tests. +- `pnpm run typecheck` + - Passed. +- `pnpm run lint:check` + - Passed with 0 errors and 5 existing warnings in `src/pages/Home/index.tsx` + and `src/pages/Makelore/index.tsx`. +- `pnpm test` + - Passed: 211 regular files / 1,730 tests plus the isolated pressure file / + 1 test; 3 tests skipped. +- `pnpm run build:vite` + - Passed for Renderer, Electron Main, Preload, and release utility worker; + existing Vite dynamic-import and chunk-size warnings remain. +- `node ./node_modules/@playwright/test/cli.js test tests/e2e/pi-coding-first-chat.spec.ts` + - Passed: 2 Electron tests, including persisted quota feedback, automatic + older-history loading, and scroll-anchor preservation. +- `git diff --check` + - Passed. + +## Follow-ups + +- Integration should account for the ready progress-message task because both + tasks intentionally touch `CodingConversationTimeline.tsx` and its unit/E2E + coverage; the edited concerns are separate. + +## Promotion Candidates + +- Target canonical documents: `README.md` and the integrated current-state + summary. +- Proposal: state that Pi compaction changes model context but does not remove + visible active-branch history, and that recognized Provider quota failures + remain actionable after reopening a conversation. +- Evidence: focused projector/Renderer/proxy tests, the 1,731-test full unit + run including pressure coverage, the production build, and the two focused + Electron E2E scenarios all pass. +- Future impact: troubleshooting and future history work should preserve the + distinction between Pi context retention and product-visible session + history, and should reuse the redacted Provider-failure projection. +- Semantic conflicts: none known; the peer progress-message task overlaps the + timeline files but not these semantics. +- Human confirmation required: no product-direction choice is known to be + required, but the integration owner must decide whether this detail belongs + in canonical user-facing documentation. diff --git a/electron/api/routes/ai-proxy.ts b/electron/api/routes/ai-proxy.ts index ce59208..3015793 100644 --- a/electron/api/routes/ai-proxy.ts +++ b/electron/api/routes/ai-proxy.ts @@ -86,7 +86,8 @@ function getForwardedOneApiStatus(status: number, bodyText: string): number { if (status === 502 && isAIGatewayUserContextMissing(bodyText)) { return 401; } - if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') { + if ((status === 403 || status === 429) + && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') { return 402; } if (status === 429 && isAIGatewayUpstreamSaturated(bodyText)) { diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index 72ee270..f49e511 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -6,8 +6,8 @@ import type { ConversationToolNode, PublicUsage, } from '../contracts'; -import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details'; import type { PiRpcEvent } from './rpc-client'; +import { projectPiProviderFailure } from './provider-failure'; import { subagentDetailsOfResult } from '../subagent-protocol'; import { isProductToolName, @@ -45,17 +45,6 @@ function retryFailure(message: string) { }; } -function providerFailure(message: unknown) { - if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { - return { - code: 'CODING_PROVIDER_AUTH_REQUIRED' as const, - message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', - recoverable: true, - }; - } - return retryFailure('模型服务请求失败,请稍后重试。'); -} - function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -294,7 +283,7 @@ export class PiEventProjector { ...run, status: 'error', terminalReason: 'failed', - error: providerFailure(event.finalError), + error: projectPiProviderFailure(event.finalError), }, }]; } @@ -485,7 +474,7 @@ export class PiEventProjector { ...snapshot.run, status: 'error', terminalReason: 'failed', - error: providerFailure(assistant.errorMessage), + error: projectPiProviderFailure(assistant.errorMessage), }, }]; } diff --git a/electron/coding-runtime/pi/provider-failure.ts b/electron/coding-runtime/pi/provider-failure.ts new file mode 100644 index 0000000..346a5e4 --- /dev/null +++ b/electron/coding-runtime/pi/provider-failure.ts @@ -0,0 +1,25 @@ +import type { CodingRuntimePublicError } from '../contracts'; +import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details'; +import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind'; + +export function projectPiProviderFailure(message: unknown): CodingRuntimePublicError { + if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { + return { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + recoverable: true, + }; + } + if (typeof message === 'string' && getAIGatewayErrorKind(message) === 'quota_exhausted') { + return { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }; + } + return { + code: 'CODING_RUNTIME_START_FAILED', + message: '模型服务请求失败,请稍后重试。', + recoverable: true, + }; +} diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index 70c611b..b27d7a7 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -14,6 +14,7 @@ import type { } from './event-projector'; import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol'; import { subagentDetailsOfResult } from '../subagent-protocol'; +import { projectPiProviderFailure } from './provider-failure'; export interface PiSessionSnapshotInput { snapshot: ConversationSnapshot; @@ -106,23 +107,6 @@ function activePath(entriesValue: unknown, leafId: unknown): SessionEntryRecord[ return path.reverse(); } -function retainedTail(path: SessionEntryRecord[]): SessionEntryRecord[] { - const compactionIndex = path.findLastIndex((entry) => entry.type === 'compaction'); - if (compactionIndex < 0) return path; - const compaction = path[compactionIndex]; - const firstKeptEntryId = compaction.firstKeptEntryId; - if (typeof firstKeptEntryId !== 'string') unreadable(); - const firstKeptIndex = path.findIndex( - (entry, index) => index < compactionIndex && entry.id === firstKeptEntryId, - ); - if (firstKeptIndex < 0) unreadable(); - return [ - compaction, - ...path.slice(firstKeptIndex, compactionIndex), - ...path.slice(compactionIndex + 1), - ]; -} - async function contentBlocks( messageId: string, content: unknown, @@ -265,6 +249,19 @@ async function projectEntries( ...(stopReason ? { stopReason } : {}), }; nodes.push(node); + if (message.stopReason === 'error') { + const failure = projectPiProviderFailure(message.errorMessage); + if (failure.code === 'CODING_PROVIDER_AUTH_REQUIRED' + || failure.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED') { + nodes.push({ + kind: 'notice', + id: `${messageId}:provider-error`, + code: failure.code, + level: 'error', + message: failure.message, + }); + } + } if (!Array.isArray(message.content)) continue; for (const value of message.content) { const block = asRecord(value); @@ -310,6 +307,16 @@ function reconcileLiveIds( liveNodes: ConversationNode[], ): ConversationNode[] { const used = new Set(); + const durableCompactions = durableNodes.filter((node) => node.kind === 'compaction'); + const liveCompactions = liveNodes.filter((node) => node.kind === 'compaction'); + const compactionMatches = new Map>(); + for ( + let durableIndex = durableCompactions.length - 1, liveIndex = liveCompactions.length - 1; + durableIndex >= 0 && liveIndex >= 0; + durableIndex -= 1, liveIndex -= 1 + ) { + compactionMatches.set(durableCompactions[durableIndex].id, liveCompactions[liveIndex]); + } return durableNodes.map((node) => { if (node.kind === 'message') { const live = liveNodes.find((candidate) => candidate.kind === 'message' @@ -338,10 +345,8 @@ function reconcileLiveIds( }; } if (node.kind === 'compaction') { - const live = liveNodes.findLast( - (candidate) => candidate.kind === 'compaction' && !used.has(candidate.id), - ); - if (!live || live.kind !== 'compaction') return node; + const live = compactionMatches.get(node.id); + if (!live) return node; used.add(live.id); return { ...node, id: live.id, runId: live.runId }; } @@ -375,7 +380,7 @@ export async function projectPiSessionSnapshot( const response = asRecord(input.entries); const state = asRecord(input.state); if (!response || !state) unreadable(); - const path = retainedTail(activePath(response.entries, response.leafId)); + const path = activePath(response.entries, response.leafId); const durableNodes = await projectEntries(path, input); const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes); return { diff --git a/shared/ai-gateway-error-kind.ts b/shared/ai-gateway-error-kind.ts index d512b7d..f16ca51 100644 --- a/shared/ai-gateway-error-kind.ts +++ b/shared/ai-gateway-error-kind.ts @@ -3,8 +3,10 @@ export type AIGatewayErrorKind = 'quota_exhausted' | 'authentication_invalid'; export function isTokenBalanceExhausted(message: string | null | undefined): boolean { const normalized = message?.trim().toLowerCase() ?? ''; return normalized.includes('token_balance_exhausted') + || normalized.includes('token_point_balance_exhausted') || normalized.includes('token balance exhausted') - || normalized.includes('balance exhausted'); + || normalized.includes('balance exhausted') + || normalized.includes('词元点数余额不足'); } export function getAIGatewayErrorKind(message: string | null | undefined): AIGatewayErrorKind | null { diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 111e0a9..8b39b9d 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -28,6 +28,7 @@ export type CodingRuntimeErrorCode = | 'CODING_RUNTIME_READY_TIMEOUT' | 'CODING_RUNTIME_PROTOCOL_ERROR' | 'CODING_PROVIDER_AUTH_REQUIRED' + | 'CODING_PROVIDER_QUOTA_EXHAUSTED' | 'CODING_MODEL_UNAVAILABLE' | 'CODING_SESSION_UNREADABLE' | 'CODING_STORAGE_WRITE_FAILED' diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts index 985ba23..1a219dd 100644 --- a/shared/coding-conversation-reducer.ts +++ b/shared/coding-conversation-reducer.ts @@ -60,6 +60,7 @@ const ERROR_CODES = new Set([ 'CODING_RUNTIME_READY_TIMEOUT', 'CODING_RUNTIME_PROTOCOL_ERROR', 'CODING_PROVIDER_AUTH_REQUIRED', + 'CODING_PROVIDER_QUOTA_EXHAUSTED', 'CODING_MODEL_UNAVAILABLE', 'CODING_SESSION_UNREADABLE', 'CODING_STORAGE_WRITE_FAILED', diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 2a82f2c..04af87e 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -1265,6 +1265,19 @@ function processItemIsFailed(item: ProcessItem): boolean { return item.kind === 'notice' && item.node.level === 'error'; } +function persistedProviderFailureMessage(items: ProcessItem[]): string | undefined { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item?.kind === 'notice' + && item.node.level === 'error' + && (item.node.code === 'CODING_PROVIDER_AUTH_REQUIRED' + || item.node.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED')) { + return item.node.message; + } + } + return undefined; +} + function processGroupIsFailed( items: ProcessItem[], run: ConversationRunState, @@ -1326,10 +1339,13 @@ const ProcessGroup = memo(function ProcessGroup({ ? runningDurationLabel(run, now) : settledDurationLabel(run) : null; + const failureMessage = failed + ? (latest ? run.error?.message : undefined) ?? persistedProviderFailureMessage(items) + : undefined; const summary = active ? `处理中${duration ? ` ${duration}` : ''}` : failed - ? `处理失败${duration ? ` · ${duration}` : ''}` + ? `${failureMessage ?? '处理失败'}${duration ? ` · ${duration}` : ''}` : `已处理${duration ? ` ${duration}` : ''}`; return ( @@ -1491,11 +1507,33 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli const nodes = useCodingConversationStore(selectNodes); const run = useCodingConversationStore(selectRun); const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW); + const scrollRef = useRef(null); + const stickToBottomRef = useRef(true); + const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); const windowStart = Math.max(0, nodes.length - visibleLimit); const visibleNodes = nodes.slice(windowStart); const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]); - const scrollRef = useRef(null); - const stickToBottomRef = useRef(true); + + const loadEarlier = useCallback(() => { + if (windowStart === 0 || prependAnchorRef.current) return; + const element = scrollRef.current; + if (!element) return; + prependAnchorRef.current = { + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + stickToBottomRef.current = false; + setVisibleLimit((current) => Math.min(nodes.length, current + WINDOW_STEP)); + }, [nodes.length, windowStart]); + + useLayoutEffect(() => { + const anchor = prependAnchorRef.current; + if (!anchor) return; + prependAnchorRef.current = null; + const element = scrollRef.current; + if (!element) return; + element.scrollTop = anchor.scrollTop + (element.scrollHeight - anchor.scrollHeight); + }, [visibleLimit]); useLayoutEffect(() => { if (!stickToBottomRef.current) return; @@ -1513,6 +1551,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli onScroll={(event) => { const element = event.currentTarget; stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64; + if (element.scrollTop <= 48) loadEarlier(); }} >
@@ -1521,7 +1560,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli type="button" variant="ghost" className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]" - onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)} + onClick={loadEarlier} > 加载更早内容 diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index b8fad8d..48b662b 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -143,6 +143,12 @@ async function installCodingFirstChatHost( : null, modelResolution: featureComplete ? 'resolved' : 'required', }; + const historyConversation = { + ...conversation, + id: 'conversation-pi-history', + title: 'History and quota', + updatedAt: '2026-08-23T23:58:00.000Z', + }; const snapshot = { schemaVersion: 1, conversation: { @@ -353,6 +359,39 @@ async function installCodingFirstChatHost( error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true }, }, }; + const historySnapshot = { + ...snapshot, + conversation: { + ...snapshot.conversation, + id: historyConversation.id, + title: historyConversation.title, + }, + nodes: [ + ...Array.from({ length: 150 }, (_, index) => ({ + kind: 'message', + id: `history-message-${index}`, + role: 'user', + status: 'complete', + blocks: [{ + kind: 'text', + id: `history-message-${index}:content:0`, + text: `History message ${index}`, + status: 'complete', + }], + })), + { + kind: 'notice', + id: 'history-quota-notice', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error', + message: '词元点数余额不足,请充值后重试。', + }, + ], + run: { status: 'idle' }, + queue: { items: [] }, + pendingInteractions: [], + worker: { status: 'ready', generation: 1 }, + }; const respond = (json: unknown, status = 200) => ({ ok: true, data: { status, ok: status >= 200 && status < 300, json }, @@ -453,7 +492,7 @@ async function installCodingFirstChatHost( if (path === `/api/coding/projects/conversations?projectId=${project.id}`) { return respond({ conversations: featureComplete - ? [conversation, secondConversation] + ? [conversation, secondConversation, historyConversation] : state.conversationCreated ? [conversation] : [], @@ -478,6 +517,9 @@ async function installCodingFirstChatHost( if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) { return respond({ snapshot: secondSnapshot }); } + if (path === `/api/coding/conversations/${historyConversation.id}/snapshot`) { + return respond({ snapshot: historySnapshot }); + } if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') { return respond({ acceptance: { @@ -890,6 +932,29 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model await expect(page.getByRole('textbox')).toHaveValue(''); await page.getByRole('button', { name: '恢复' }).click(); + await builderConversations.getByRole('button', { name: 'History and quota' }).click(); + const historyProcess = page.getByTestId('coding-process-group'); + await expect(historyProcess.locator('summary').first()) + .toContainText('词元点数余额不足,请充值后重试。'); + await expect(page.getByText('History message 0')).toHaveCount(0); + await expect(page.getByText('History message 31')).toHaveCount(1); + const historyTimeline = page.getByTestId('coding-conversation-timeline'); + const beforeHeight = await historyTimeline.evaluate((element) => { + element.scrollTop = 0; + const height = element.scrollHeight; + element.dispatchEvent(new Event('scroll', { bubbles: true })); + return height; + }); + await expect(page.getByText('History message 0')).toHaveCount(1); + const anchoredScroll = await historyTimeline.evaluate((element) => ({ + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + })); + expect(anchoredScroll.scrollTop).toBeGreaterThan(0); + expect(Math.abs( + anchoredScroll.scrollTop - (anchoredScroll.scrollHeight - beforeHeight), + )).toBeLessThanOrEqual(2); + await expect(page.getByText(/编程工具|分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0); const state = await readState(electronApp); expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true); diff --git a/tests/unit/ai-proxy-routes.test.ts b/tests/unit/ai-proxy-routes.test.ts index dfa98a6..4b70a71 100644 --- a/tests/unit/ai-proxy-routes.test.ts +++ b/tests/unit/ai-proxy-routes.test.ts @@ -274,6 +274,40 @@ describe('ai proxy routes', () => { expect(response.body()).toBe(JSON.stringify({ error: 'user quota is not enough' })); }); + it('maps the observed token-point balance 403 to a non-retryable quota response', async () => { + seedWorksSquareAIGatewayCredential({ + accessToken: 'ws-ai-token', + expiresIn: 3600, + oneApiBaseUrl: 'https://one-api.example.com/v1', + }); + const body = JSON.stringify({ + error: { + message: '词元点数余额不足', + code: 'token_point_balance_exhausted', + type: 'one_api_error', + }, + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(body, { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAiProxyRoutes( + createRequest('POST', { model: 'deepseek-v4-flash', messages: [] }), + response.res, + new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'), + {} as never, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(response.statusCode).toBe(402); + expect(response.body()).toBe(body); + }); + it('maps rolling-window quota exhaustion to a non-retryable response status', async () => { seedWorksSquareAIGatewayCredential({ accessToken: 'ws-ai-token', diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index 6bef893..9d82ca2 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -631,7 +631,83 @@ describe('CodingConversationTimeline', () => { .toBeVisible(); }); - it('windows long timelines and loads earlier nodes only on demand', async () => { + it('shows an actionable non-retryable quota failure in the process summary', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import( + '@/pages/Chat/CodingConversationTimeline' + ); + const base = createProductSnapshot('conversation-quota-failure', 1); + const snapshot = { + ...base, + run: { + status: 'idle' as const, + runId: 'run-quota-failure', + startedAt: 1_000, + settledAt: 6_000, + terminalReason: 'failed' as const, + error: { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED' as const, + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }, + }, + nodes: [{ + kind: 'tool' as const, + id: 'tool-quota-failure', + toolCallId: 'call-quota-failure', + toolName: 'bash', + title: '执行命令', + inputText: '', + status: 'error' as const, + output: [], + }], + }; + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-quota-failure', + workerGeneration: 1, + seq: snapshot.cursor.seq, + snapshot, + }); + + render(); + + expect(within(screen.getByTestId('coding-process-group')) + .getByText('词元点数余额不足,请充值后重试。 · 5 秒')).toBeVisible(); + }); + + it('shows an actionable persisted Provider failure after reopening a Conversation', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import( + '@/pages/Chat/CodingConversationTimeline' + ); + const base = createProductSnapshot('conversation-persisted-quota-failure', 1); + const snapshot = { + ...base, + run: { status: 'idle' as const }, + nodes: [{ + kind: 'notice' as const, + id: 'persisted-quota-notice', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error' as const, + message: '词元点数余额不足,请充值后重试。', + }], + }; + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-persisted-quota-failure', + workerGeneration: 1, + seq: snapshot.cursor.seq, + snapshot, + }); + + render(); + + expect(screen.getByTestId('coding-process-group').querySelector('summary')) + .toHaveTextContent('词元点数余额不足,请充值后重试。'); + }); + + it('windows long timelines, loads near the top, and preserves the scroll anchor', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' @@ -659,8 +735,17 @@ describe('CodingConversationTimeline', () => { expect(screen.queryByText('Notice 0')).not.toBeInTheDocument(); expect(screen.getByText('Notice 30')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '加载更早内容' })); - expect(screen.getByText('Notice 0')).toBeInTheDocument(); + const timeline = screen.getByTestId('coding-conversation-timeline'); + Object.defineProperty(timeline, 'scrollHeight', { + configurable: true, + get: () => screen.queryByText('Notice 0') ? 1_600 : 1_000, + }); + timeline.scrollTop = 0; + + fireEvent.scroll(timeline); + + await waitFor(() => expect(screen.getByText('Notice 0')).toBeInTheDocument()); + expect(timeline.scrollTop).toBe(600); }); it('does not commit the selected timeline when a hidden Conversation streams', async () => { diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 071d728..118bbed 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -472,6 +472,40 @@ describe('Pi event projector', () => { expect(JSON.stringify(snapshot)).not.toContain('secret request detail'); }); + it('projects token-point balance exhaustion as a non-retryable Provider quota failure', async () => { + const projector = new PiEventProjector({ createId: () => 'provider-quota-error-a' }); + let snapshot = emptySnapshot(); + const providerFailure = { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '403: {"error":{"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}}', + timestamp: 10, + }; + + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'message_end', + message: providerFailure, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'agent_end', + messages: [providerFailure], + willRetry: false, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' })); + + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('secret-request'); + }); + it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => { const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' }); let snapshot = emptySnapshot(); diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts index 31703d1..69c4d7e 100644 --- a/tests/unit/pi-session-projector.test.ts +++ b/tests/unit/pi-session-projector.test.ts @@ -242,7 +242,7 @@ describe('Pi session projector', () => { expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 }); }); - it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => { + it('projects the full active history around compaction and reconciles durable entries without replacing live IDs', async () => { const live: ConversationSnapshot = { ...baseSnapshot(), nodes: [ @@ -371,10 +371,11 @@ describe('Pi session projector', () => { }); expect(snapshot.nodes.map(({ kind, id }) => ({ kind, id }))).toEqual([ - { kind: 'compaction', id: 'live-compaction-a' }, + { kind: 'message', id: 'entry:entry-old-user' }, { kind: 'message', id: 'live-user-a' }, { kind: 'message', id: 'live-assistant-a' }, { kind: 'tool', id: 'live-tool-a' }, + { kind: 'compaction', id: 'live-compaction-a' }, { kind: 'message', id: 'entry:entry-after-user' }, ]); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ @@ -401,10 +402,55 @@ describe('Pi session projector', () => { compaction: 'idle', recalculating: true, }); - expect(JSON.stringify(snapshot)).not.toContain('Summarized old question'); + expect(JSON.stringify(snapshot)).toContain('Summarized old question'); expect(JSON.stringify(snapshot)).not.toContain('summary must stay hidden'); }); + it('restores persisted Provider quota failures as safe actionable notices', async () => { + const snapshot = await projectPiSessionSnapshot({ + snapshot: baseSnapshot(), + workerGeneration: 2, + state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false }, + entries: { + leafId: 'entry-quota-error', + entries: [ + { + type: 'message', + id: 'entry-quota-user', + parentId: null, + message: { role: 'user', content: 'Continue', timestamp: 1 }, + }, + { + type: 'message', + id: 'entry-quota-error', + parentId: 'entry-quota-user', + message: { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '403: {"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}', + timestamp: 2, + }, + }, + ], + }, + }); + + expect(snapshot.nodes).toContainEqual(expect.objectContaining({ + kind: 'message', + id: 'entry:entry-quota-error', + status: 'error', + })); + expect(snapshot.nodes).toContainEqual({ + kind: 'notice', + id: 'entry:entry-quota-error:provider-error', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error', + message: '词元点数余额不足,请充值后重试。', + }); + expect(JSON.stringify(snapshot)).not.toContain('secret-request'); + }); + it('projects persisted images through attachment storage without retaining base64', async () => { const rawImage = 'A'.repeat(1024 * 1024); const projected = await projectPiSessionSnapshot({