fix: restore coding history and quota feedback

This commit is contained in:
inman
2026-09-01 11:46:04 +08:00
parent 38f85f6b5e
commit d523b72cf8
14 changed files with 480 additions and 49 deletions

View File

@@ -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.

View File

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

View File

@@ -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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@@ -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),
},
}];
}

View File

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

View File

@@ -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<string>();
const durableCompactions = durableNodes.filter((node) => node.kind === 'compaction');
const liveCompactions = liveNodes.filter((node) => node.kind === 'compaction');
const compactionMatches = new Map<string, Extract<ConversationNode, { kind: 'compaction' }>>();
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 {

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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',

View File

@@ -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<HTMLDivElement | null>(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<HTMLDivElement | null>(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();
}}
>
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
@@ -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}
>
</Button>

View File

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

View File

@@ -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',

View File

@@ -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(<CodingConversationTimeline conversationId="conversation-quota-failure" />);
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(<CodingConversationTimeline conversationId="conversation-persisted-quota-failure" />);
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 () => {

View File

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

View File

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