diff --git a/.project-docs/30-worklog/tasks/20260812-first-chat-fix-b84fd29c.md b/.project-docs/30-worklog/tasks/20260812-first-chat-fix-b84fd29c.md new file mode 100644 index 0000000..75cdefa --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-first-chat-fix-b84fd29c.md @@ -0,0 +1,70 @@ +# Task: Fix first conversation latency + +## Identity + +- Task ID: 20260812-first-chat-fix-b84fd29c +- Mode: Feature +- Branch: codex/20260812-first-chat-fix-b84fd29c-first-chat-fix +- Worktree: D:\Datas\OthersProjects\makelore-first-chat-fix-b84fd29c +- Base commit: b02c3f22e1b1fe394dd44cf1a5cb50bc8f809a4e +- Owner: codex +- Status: Completed + +## Scope + +- Bound the AI programming retry loop when the upstream model group is saturated, so the first conversation fails promptly with an actionable message instead of appearing stuck for an unbounded period. +- Shorten the first-send local critical path by avoiding a blocking history read for a just-created, known-empty OpenCode session. +- Add focused regression coverage at the AI proxy and ChatPanel seams, then verify the affected Renderer/Main build paths. +- Do not change AI Canvas behavior, Agent templates, provider configuration semantics, or the general OpenCode session model. + +## Intent And Constraints + +- Preserve Renderer -> Host API -> Electron Main -> OpenCode ownership boundaries. +- Keep true quota exhaustion non-retryable and distinguish it from temporary upstream saturation. +- Use a bounded response-status projection at the Host AI proxy rather than adding a second client-side retry controller that would compete with OpenCode. +- Preserve lazy contact selection: opening an unused Agent must not create an empty runtime session or start the runtime. +- Keep the change surgical and test-first; avoid unrelated loading-state or bootstrap refactors in this task. + +## Outcome + +- Confirmed the captured long first-conversation wait was dominated by repeated upstream saturation retries, while the local first-send chain also contained a redundant blocking read of a just-created empty session. +- Added a narrow shared saturation classifier and changed only explicit upstream-group saturation responses from upstream `429` to Host-projected `400`. OpenCode 1.18.9 treats that status as terminal, so the session no longer retries this known saturation response indefinitely. Quota exhaustion remains projected to `402`, and generic rate limits remain `429`. +- Added an explicit `selectSession(..., { loadMessages: false })` fast path. It skips the history request only when the store already owns a cache entry for the session; unknown sessions still load defensively, and ordinary historical selection keeps its refresh behavior. +- Changed the ChatPanel history-loading guard to distinguish an unknown empty transcript from a known-empty cache entry, while still accepting restored visible messages whose cache map has not yet been hydrated. +- Added deterministic unit and Electron regression coverage. The Electron test keeps the new session's history GET pending and proves that the prompt POST is already sent. + +## Verification + +- TDD RED evidence: + - Saturation route test expected a terminal response but observed the original `429`; the new shared classifier module was not yet present. + - First-send test observed `create -> history -> history` and no prompt while the known-empty history request remained pending. + - A first implementation made two existing ChatPanel restoration tests red; they were used to correct the history guard before completion. +- Focused unit regression command passed: 4 files, 14 selected tests passed (`opencode-error-details`, `ai-proxy-routes`, `opencode-store`, `opencode-chat-panel`). +- Corrected ChatPanel regression command passed: 4 selected tests passed, covering the two restored existing cases, first-send fast path, and lazy contact selection. +- `pnpm run typecheck`: passed. +- ESLint for all changed product, unit-test, and Electron-test files: passed. +- `pnpm run build:vite`: passed for Renderer, Electron Main, and Preload. +- `pnpm exec playwright test tests/e2e/opencode-first-chat.spec.ts`: passed, 1 test. +- `pnpm test`: 1548 of 1551 tests passed. The three remaining failures reproduce on `main` and are outside the changed paths: two pre-existing `opencode-manager` timing/generation tests and one `youth-plain-language-skill` fixture failure caused by the absent repository `.opencode/agent` directory. +- `git diff --check`: passed; Git only reported the repository's normal LF-to-CRLF checkout warnings. + +## Follow-ups + +- Revisit the internal `429 -> 400` compatibility projection whenever the bundled OpenCode version changes; the current choice is tied to OpenCode 1.18.9 retry semantics. +- The broader cold-start bootstrap still performs duplicate project/config/session/status work and uses a coarse global loading flag. Those are separate optimization candidates and were intentionally left out of this surgical fix. +- Repair the unrelated baseline unit failures separately: the two OpenCode manager timing tests and the missing `.opencode/agent` test fixture. + +## Promotion Candidates + +- Target canonical document: `.project-docs/20-architecture/data-flow.md` or the closest accepted OpenCode runtime/proxy contract section. + - Proposal: record that the Host AI proxy may apply narrow internal HTTP status projections to match the pinned OpenCode retry contract; explicit upstream-group saturation is terminal, quota exhaustion remains a distinct terminal kind, and generic rate limits remain retryable. + - Evidence: `shared/opencode-error-details.ts`, `electron/api/routes/ai-proxy.ts`, focused route tests, and the captured repeated-saturation diagnosis. + - Future impact: OpenCode upgrades must revalidate status retry behavior before retaining or changing the projection. + - Semantic conflicts: none identified with current architecture; the Renderer -> Host API -> Electron Main -> OpenCode boundary remains intact. + - Human confirmation required: no, unless canonical maintainers prefer a different internal error transport than HTTP status projection. +- Target canonical document: `.project-docs/20-architecture/data-flow.md` or the OpenCode session-state contract. + - Proposal: record `sessionMessagesBySessionId` own-key semantics as `absent = history unknown`, `present [] = history known empty`; fast selection may skip a history fetch only for the latter. + - Evidence: `src/stores/opencode.ts`, `src/pages/Chat/OpencodeChatPanel.tsx`, store/Panel unit tests, and `tests/e2e/opencode-first-chat.spec.ts`. + - Future impact: preserves a safe performance seam for newly created sessions without making historical session selection stale. + - Semantic conflicts: none identified. + - Human confirmation required: no. 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');