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

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