修复首次会话等待与上游饱和重试

This commit is contained in:
2026-08-12 23:26:19 +08:00
parent b02c3f22e1
commit 08da976634
10 changed files with 506 additions and 14 deletions

View File

@@ -336,20 +336,21 @@ describe('ai proxy routes', () => {
expect(response.body()).toContain('works_square_gateway_authorize_failed');
});
it('logs a sanitized one-api error summary when the upstream group is saturated', async () => {
it('maps explicit upstream group saturation to a non-retryable response status', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const body = JSON.stringify({
error: {
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
code: 'rate_limit_exceeded',
type: 'one_api_error',
},
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
error: {
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
code: 'rate_limit_exceeded',
type: 'one_api_error',
},
}), {
new Response(body, {
status: 429,
headers: { 'content-type': 'application/json' },
}),
@@ -364,8 +365,10 @@ describe('ai proxy routes', () => {
{} as never,
);
expect(response.statusCode).toBe(429);
expect(response.body()).toContain('当前分组上游负载已饱和');
expect(fetchMock).toHaveBeenCalledOnce();
expect(response.statusCode).toBe(400);
expect(response.header('content-type')).toContain('application/json');
expect(response.body()).toBe(body);
expect(loggerWarnMock).toHaveBeenCalledWith(
'[ai-proxy] One-api returned non-success response',
expect.objectContaining({
@@ -380,6 +383,40 @@ describe('ai proxy routes', () => {
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('ws-ai-token');
});
it('preserves a generic rate-limit response without explicit saturation evidence', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const body = JSON.stringify({
error: {
message: 'Rate limit exceeded',
code: 'rate_limit_exceeded',
type: 'one_api_error',
},
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(body, {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'qwen3.7-max', 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(429);
expect(response.body()).toBe(body);
});
it('strips decoded compression headers from proxied responses', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',

View File

@@ -1023,6 +1023,60 @@ describe('OpencodeChatPanel', () => {
expect(hostApiFetchMock).not.toHaveBeenCalledWith('/api/opencode/sessions', expect.objectContaining({ method: 'POST' }));
});
it('submits the first prompt without waiting for the known-empty session history', async () => {
const activeProject = {
id: 'prj_first_prompt',
path: 'D:/repo/first-prompt',
name: 'first-prompt',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
lastOpenedAt: '2026-07-12T00:00:00.000Z',
};
const config = createConfiguredProjectConfig();
const requestOrder: string[] = [];
const pendingHistory = new Promise<never>(() => undefined);
useProjectConfigStore.setState({ configsByProjectId: { [activeProject.id]: config } });
useOpencodeStore.setState({
status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' },
projects: [activeProject],
activeProject,
sessions: [],
selectedSessionId: null,
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/status') {
return { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' };
}
if (path === '/api/opencode/projects') return { projects: [activeProject], activeProject };
if (path === '/api/opencode/config-summary') return useOpencodeStore.getState().runtimeConfigSummary;
if (path === '/api/opencode/sessions' && init?.method === 'POST') {
requestOrder.push('create');
return { success: true, session: { id: 'ses_first_prompt', title: 'New conversation' } };
}
if (path === '/api/opencode/sessions') return { sessions: [] };
if (path === '/api/opencode/sessions/status') return { statuses: { ses_first_prompt: { type: 'busy' } } };
if (path === '/api/opencode/sessions/ses_first_prompt/messages' && init?.method === 'POST') {
requestOrder.push('prompt');
return { success: true };
}
if (path === '/api/opencode/sessions/ses_first_prompt/messages') {
requestOrder.push('history');
return pendingHistory;
}
if (path === '/api/opencode/sessions/ses_first_prompt/todos') return { todos: [] };
throw new Error(`Unexpected path ${path}`);
});
render(<OpencodeChatPanel variant="main" />);
fireEvent.click(await screen.findByTestId('project-agent-chat-game-art'));
const composer = screen.getByRole('textbox');
fireEvent.change(composer, { target: { value: 'Build the first scene' } });
fireEvent.submit(screen.getByTestId('opencode-message-composer'));
await waitFor(() => expect(requestOrder).toContain('prompt'));
expect(requestOrder.indexOf('prompt')).toBeLessThan(requestOrder.indexOf('history'));
});
it('does not create an empty session while selecting a contact without linked history', async () => {
const activeProject = { id: 'prj_cold_history', path: 'D:/repo/cold-history', name: 'cold-history', createdAt: '2026-07-13T00:00:00.000Z', updatedAt: '2026-07-13T00:00:00.000Z', lastOpenedAt: '2026-07-13T00:00:00.000Z' };
const config = createConfiguredProjectConfig();

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isOpencodeUpstreamSaturated } from '../../shared/opencode-error-details';
describe('isOpencodeUpstreamSaturated', () => {
it.each([
'当前分组上游负载已饱和,请稍后再试',
JSON.stringify({ error: { message: 'UPSTREAM capacity is SATURATED; try later' } }),
])('classifies explicit upstream saturation evidence: %s', (message) => {
expect(isOpencodeUpstreamSaturated(message)).toBe(true);
});
it.each([
'rate_limit_exceeded',
JSON.stringify({ error: { code: 'rate_limit_exceeded', message: 'Rate limit exceeded' } }),
'upstream request failed',
'provider saturated',
'user quota is not enough',
])('does not broaden saturation classification to other retry failures: %s', (message) => {
expect(isOpencodeUpstreamSaturated(message)).toBe(false);
});
});

View File

@@ -1251,6 +1251,40 @@ describe('opencode store', () => {
expect(useOpencodeStore.getState().sessionMessages).toEqual(loaded);
});
it('loads messages when a fast session selection has no known cache entry', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
vi.mocked(hostApiFetch).mockResolvedValueOnce({
messages: [{ id: 'msg_1', role: 'assistant', content: 'Loaded defensively.' }],
});
const loaded = await useOpencodeStore.getState().selectSession(
'ses_uncached',
{ loadMessages: false },
);
expect(hostApiFetch).toHaveBeenCalledWith('/api/opencode/sessions/ses_uncached/messages');
expect(loaded).toEqual([
expect.objectContaining({ id: 'msg_1', role: 'assistant' }),
]);
});
it('selects a session from a known-empty cache without loading history', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
useOpencodeStore.setState({
sessionMessagesBySessionId: { ses_new: [] },
});
const loaded = await useOpencodeStore.getState().selectSession(
'ses_new',
{ loadMessages: false },
);
expect(loaded).toEqual([]);
expect(useOpencodeStore.getState().selectedSessionId).toBe('ses_new');
expect(useOpencodeStore.getState().sessionMessages).toEqual([]);
expect(hostApiFetch).not.toHaveBeenCalledWith('/api/opencode/sessions/ses_new/messages');
});
it('streams assistant updates and tool status before finalizing transcript', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
const source = new MockEventSource('/api/opencode/events?sessionId=ses_1');