diff --git a/electron/api/routes/ai-proxy.ts b/electron/api/routes/ai-proxy.ts index 59f0665..c4fae22 100644 --- a/electron/api/routes/ai-proxy.ts +++ b/electron/api/routes/ai-proxy.ts @@ -9,6 +9,7 @@ import { import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { logger } from '../../utils/logger'; import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind'; +import { isOpencodeUpstreamSaturated } from '../../../shared/opencode-error-details'; const AI_PROXY_PREFIX = '/api/ai-proxy/v1'; const MAX_ERROR_LOG_MESSAGE_LENGTH = 600; @@ -81,6 +82,9 @@ function getForwardedOneApiStatus(status: number, bodyText: string): number { if (status === 429 && getOpencodeErrorKind(bodyText) === 'quota_exhausted') { return 402; } + if (status === 429 && isOpencodeUpstreamSaturated(bodyText)) { + return 400; + } return status; } diff --git a/shared/opencode-error-details.ts b/shared/opencode-error-details.ts new file mode 100644 index 0000000..a8a0d5c --- /dev/null +++ b/shared/opencode-error-details.ts @@ -0,0 +1,6 @@ +export function isOpencodeUpstreamSaturated(message: string | null | undefined): boolean { + const normalized = message?.trim().toLowerCase() ?? ''; + if (!normalized) return false; + return normalized.includes('当前分组上游负载已饱和') + || (normalized.includes('upstream') && normalized.includes('saturat')); +} diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index 78d1cd8..5b09d91 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -1355,7 +1355,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev }, [authenticationFailureKey, logout]); useEffect(() => { - if (!selectedSessionId || sessionMessages.length > 0 || selectedSessionRunning) return; + if ( + !selectedSessionId + || sessionMessages.length > 0 + || Object.prototype.hasOwnProperty.call(sessionMessagesBySessionId, selectedSessionId) + || selectedSessionRunning + ) return; if (!activeProject || status.state !== 'running') return; void loadSessionMessages(selectedSessionId).catch(() => undefined); }, [ @@ -1364,6 +1369,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev selectedSessionId, selectedSessionRunning, sessionMessages.length, + sessionMessagesBySessionId, status.state, ]); @@ -1718,7 +1724,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev const createdSessionId = getSessionId(session); if (!createdSessionId) throw new Error('运行时未返回会话 ID'); await linkProjectSession(activeProject.id, createdSessionId, submissionAgent.id); - await selectSession(createdSessionId); + await selectSession(createdSessionId, { loadMessages: false }); submissionSession = { id: createdSessionId, generation: submissionSession.generation + 1, diff --git a/src/stores/opencode.ts b/src/stores/opencode.ts index 62f17bd..fd07041 100644 --- a/src/stores/opencode.ts +++ b/src/stores/opencode.ts @@ -210,7 +210,7 @@ interface OpencodeState { deleteSession: (sessionId: string) => Promise; abortSession: (sessionId: string) => Promise; clearSessionSelection: () => void; - selectSession: (sessionId: string) => Promise; + selectSession: (sessionId: string, options?: { loadMessages?: boolean }) => Promise; loadSessionStatuses: () => Promise; loadSessionMessages: (sessionId: string) => Promise; sendSessionMessage: (sessionId: string, text: string, options?: SendSessionMessageOptions) => Promise; @@ -3353,7 +3353,7 @@ export const useOpencodeStore = create((set, get) => ({ }); }, - async selectSession(sessionId) { + async selectSession(sessionId, options) { const state = get(); sessionDiffLoadSequence += 1; const projectId = getProjectId(state.activeProject); @@ -3373,6 +3373,12 @@ export const useOpencodeStore = create((set, get) => ({ sessionDiffError: null, ...errorState(null), }); + if ( + options?.loadMessages === false + && Object.prototype.hasOwnProperty.call(state.sessionMessagesBySessionId, sessionId) + ) { + return state.sessionMessagesBySessionId[sessionId] ?? []; + } return await get().loadSessionMessages(sessionId); }, diff --git a/tests/e2e/opencode-first-chat.spec.ts b/tests/e2e/opencode-first-chat.spec.ts new file mode 100644 index 0000000..4ea04a3 --- /dev/null +++ b/tests/e2e/opencode-first-chat.spec.ts @@ -0,0 +1,254 @@ +import type { ElectronApplication } from 'playwright-core'; +import { expect, getStableWindow, test } from './fixtures/electron'; + +type CapturedRequest = { + path: string; + method: string; + body?: Record; +}; + +async function installFirstChatHost(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(async () => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + type MainState = { + captured: CapturedRequest[]; + promptPosted: boolean; + releaseInitialHistory: (() => void) | null; + }; + const mainGlobal = globalThis as typeof globalThis & { + __makeloreFirstChatE2EState?: MainState; + }; + const state: MainState = { + captured: [], + promptPosted: false, + releaseInitialHistory: null, + }; + mainGlobal.__makeloreFirstChatE2EState = state; + + const project = { + id: 'prj_first_chat_e2e', + path: 'D:/e2e/first-chat', + name: 'first-chat', + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:00:00.000Z', + lastOpenedAt: '2026-08-12T00:00:00.000Z', + }; + const agent = { + id: 'game-development', + avatarId: 'avatar-01', + roleName: '游戏开发', + name: '游戏开发', + builtIn: false, + enabled: true, + model: 'niancode-user-models/qwen3.7-plus', + skillIds: [], + responsibility: { + mission: '实现并验证项目功能。', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + }; + const config = { + schemaVersion: 1, + projectType: 'custom', + initialized: true, + superpowersEnabled: false, + defaultModel: 'niancode-user-models/qwen3.7-plus', + agents: [agent], + knowledgeDirectory: 'knowledge', + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:00:00.000Z', + }; + const session = { + id: 'ses_first_chat_e2e', + title: '新对话', + agent: agent.id, + updatedAt: '2026-08-12T00:00:00.000Z', + }; + let conversationState = { + schemaVersion: 1, + sessions: [] as Array>, + updatedAt: '2026-08-12T00:00:00.000Z', + }; + const respond = (json: unknown, responseStatus = 200) => ({ + ok: true, + data: { + status: responseStatus, + ok: responseStatus >= 200 && responseStatus < 300, + json, + }, + }); + + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async ( + _event, + request: { path?: string; method?: string; body?: string | null }, + ) => { + const path = request.path ?? ''; + const method = request.method ?? 'GET'; + const body = request.body + ? JSON.parse(request.body) as Record + : undefined; + state.captured.push({ path, method, ...(body ? { body } : {}) }); + + if (path === '/api/opencode/status') { + return respond({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }); + } + if (path === '/api/opencode/health') { + return respond({ ok: true, status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' } }); + } + if (path === '/api/opencode/projects' || path.startsWith('/api/opencode/projects?')) { + return respond({ projects: [project], activeProject: project }); + } + if (path === '/api/opencode/projects/active' && method === 'GET') { + return respond({ projects: [project], activeProject: project }); + } + if (path.startsWith('/api/opencode/projects/config?')) { + return respond({ status: 'valid', config, knowledgeFiles: [] }); + } + if (path.startsWith('/api/opencode/projects/template?')) { + return respond({ status: 'missing' }); + } + if (path.startsWith('/api/opencode/projects/conversations?')) { + return respond({ state: conversationState }); + } + if (path === '/api/opencode/projects/conversations' && method === 'POST') { + conversationState = { + schemaVersion: 1, + sessions: [{ + sessionId: session.id, + agentId: agent.id, + archivedAt: null, + unreadCount: 0, + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:00:00.000Z', + }], + updatedAt: '2026-08-12T00:00:00.000Z', + }; + return respond({ success: true, state: conversationState }); + } + if (path === '/api/opencode/config-summary') { + return respond({ + model: 'niancode-user-models/qwen3.7-plus', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + }); + } + if (path === '/api/provider-accounts') { + return respond([{ + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + model: 'qwen3.7-plus', + enabled: true, + isDefault: true, + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:00:00.000Z', + }]); + } + if (path === '/api/provider-accounts/key-info') { + return respond([{ + accountId: 'niancode-user-models', + hasKey: true, + keyMasked: 'sk-***', + }]); + } + if (path === '/api/provider-vendors') return respond([]); + if (path === '/api/provider-accounts/default') return respond({ accountId: 'niancode-user-models' }); + if (path === '/api/opencode/sessions' && method === 'POST') { + return respond({ success: true, session }); + } + if (path === '/api/opencode/sessions') return respond({ sessions: [] }); + if (path === '/api/opencode/sessions/status') { + return respond({ + statuses: { + [session.id]: { type: state.promptPosted ? 'idle' : 'busy' }, + }, + }); + } + if (path === `/api/opencode/sessions/${session.id}/messages` && method === 'GET') { + await new Promise((resolve) => { + state.releaseInitialHistory = resolve; + }); + return respond({ messages: [] }); + } + if (path === `/api/opencode/sessions/${session.id}/messages` && method === 'POST') { + state.promptPosted = true; + return respond({ success: true }, 202); + } + if (path.endsWith('/todos')) return respond({ todos: [] }); + if (path.endsWith('/diff')) return respond({ diffs: [] }); + if (path === '/api/opencode/questions') return respond({ questions: [] }); + if (path === '/api/opencode/permissions') return respond({ permissions: [] }); + if (path === '/api/opencode/files/status') return respond({ files: [] }); + if (path === '/api/opencode/commands') return respond({ commands: [], shareEnabled: true }); + throw new Error(`Unexpected hostapi request: ${method} ${path}`); + }); + }); +} + +async function capturedRequests(electronApp: ElectronApplication): Promise { + return await electronApp.evaluate(() => { + const mainGlobal = globalThis as typeof globalThis & { + __makeloreFirstChatE2EState?: { captured: CapturedRequest[] }; + }; + return structuredClone(mainGlobal.__makeloreFirstChatE2EState?.captured ?? []); + }); +} + +async function releaseInitialHistory(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const mainGlobal = globalThis as typeof globalThis & { + __makeloreFirstChatE2EState?: { releaseInitialHistory: (() => void) | null }; + }; + mainGlobal.__makeloreFirstChatE2EState?.releaseInitialHistory?.(); + }); +} + +test('submits the first prompt without waiting for the known-empty session history', async ({ + launchElectronApp, +}) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + await installFirstChatHost(electronApp); + + try { + let page = await getStableWindow(electronApp); + await page.reload(); + page = await getStableWindow(electronApp); + const agent = page.getByTestId('project-agent-chat-game-development'); + await agent.click(); + await expect(agent).toHaveAttribute('aria-pressed', 'true'); + const composer = page.getByRole('textbox'); + await composer.fill('Build the first playable scene'); + await expect(composer).toHaveValue('Build the first playable scene'); + await page.getByTestId('opencode-message-composer').evaluate( + (form: HTMLFormElement) => form.requestSubmit(), + ); + await expect.poll(async () => (await capturedRequests(electronApp)).map( + (request) => `${request.method} ${request.path}`, + )).toContain('POST /api/opencode/sessions'); + + await expect.poll(async () => { + const requests = await capturedRequests(electronApp); + const historyPending = requests.some((request) => ( + request.path === '/api/opencode/sessions/ses_first_chat_e2e/messages' + && request.method === 'GET' + )); + const promptPosted = requests.some((request) => ( + request.path === '/api/opencode/sessions/ses_first_chat_e2e/messages' + && request.method === 'POST' + )); + return { historyPending, promptPosted }; + }).toEqual({ historyPending: true, promptPosted: true }); + } finally { + await releaseInitialHistory(electronApp); + } +}); diff --git a/tests/unit/ai-proxy-routes.test.ts b/tests/unit/ai-proxy-routes.test.ts index 9baa5bc..78854a7 100644 --- a/tests/unit/ai-proxy-routes.test.ts +++ b/tests/unit/ai-proxy-routes.test.ts @@ -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', diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 93a1e80..46e2eb4 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -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(() => 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(); + 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(); diff --git a/tests/unit/opencode-error-details.test.ts b/tests/unit/opencode-error-details.test.ts new file mode 100644 index 0000000..b31b401 --- /dev/null +++ b/tests/unit/opencode-error-details.test.ts @@ -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); + }); +}); diff --git a/tests/unit/opencode-store.test.ts b/tests/unit/opencode-store.test.ts index b5e3437..0fa8b08 100644 --- a/tests/unit/opencode-store.test.ts +++ b/tests/unit/opencode-store.test.ts @@ -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');