Merge branch 'codex/20260831-fix-history-quota-errors-6c2a91e4-fix-history-quota-errors-6c2a91e4'
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user