import type { Page } from '@playwright/test'; import type { ElectronApplication } from 'playwright-core'; import { expect, getStableWindow, test } from './fixtures/electron'; type CapturedRequest = { path: string; method: string; body?: Record; }; const SESSION_A_ID = 'ses_multichat_a'; const SESSION_B_ID = 'ses_multichat_b'; const REGISTRY_PENDING_ERROR = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。'; async function disableRendererEventSource(page: Page): Promise { await page.addInitScript(() => { class DisabledEventSource extends EventTarget { static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSED = 2; readonly CONNECTING = DisabledEventSource.CONNECTING; readonly OPEN = DisabledEventSource.OPEN; readonly CLOSED = DisabledEventSource.CLOSED; readonly url: string; readonly withCredentials = false; readyState = DisabledEventSource.OPEN; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; constructor(url: string) { super(); this.url = url; } close(): void { this.readyState = DisabledEventSource.CLOSED; } } Object.defineProperty(window, 'EventSource', { configurable: true, writable: true, value: DisabledEventSource, }); }); } async function installMultichatHost(electronApp: ElectronApplication): Promise { await electronApp.evaluate(async () => { const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); const sessionAId = 'ses_multichat_a'; const sessionBId = 'ses_multichat_b'; const registryPendingError = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。'; type MainState = { captured: CapturedRequest[]; sessionABusy: boolean; }; const mainGlobal = globalThis as typeof globalThis & { __makeloreMultichatE2EState?: MainState; }; const state: MainState = { captured: [], sessionABusy: false, }; mainGlobal.__makeloreMultichatE2EState = state; const now = '2026-08-17T10:00:00.000Z'; const project = { id: 'prj_multichat_e2e', path: 'D:/e2e/multichat', name: 'multichat', createdAt: now, updatedAt: now, lastOpenedAt: now, }; 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, defaultModel: 'niancode-user-models/qwen3.7-plus', agents: [agent], knowledgeDirectory: 'knowledge', createdAt: now, updatedAt: now, }; const sessions = [ { id: sessionAId, title: 'Session A', agent: agent.id, time: { updated: 2 } }, { id: sessionBId, title: 'Session B', agent: agent.id, time: { updated: 1 } }, ]; const conversationState = { schemaVersion: 1, sessions: [ { sessionId: sessionAId, agentId: agent.id, archivedAt: null, unreadCount: 0, createdAt: now, updatedAt: '2026-08-17T10:00:02.000Z', }, { sessionId: sessionBId, agentId: agent.id, archivedAt: null, unreadCount: 0, createdAt: now, updatedAt: '2026-08-17T10:00:01.000Z', }, ], updatedAt: '2026-08-17T10:00:02.000Z', }; const runtimeStatus = { state: 'running', port: 4096, url: 'http://127.0.0.1:4096', }; 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(runtimeStatus); if (path === '/api/opencode/health') { return respond({ ok: true, status: runtimeStatus }); } 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') { 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: now, updatedAt: now, }]); } 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') return respond({ sessions }); if (path === '/api/opencode/sessions/status') { return respond({ statuses: { [sessionAId]: { type: state.sessionABusy ? 'busy' : 'idle' }, [sessionBId]: { type: 'idle' }, }, }); } if ( (path === `/api/opencode/sessions/${sessionAId}/messages` || path === `/api/opencode/sessions/${sessionBId}/messages`) && method === 'GET' ) { return respond({ messages: [] }); } if (path === `/api/opencode/sessions/${sessionAId}/messages` && method === 'POST') { state.sessionABusy = true; return respond({ success: true }, 202); } if (path === `/api/opencode/sessions/${sessionBId}/messages` && method === 'POST') { return respond({ success: false, error: registryPendingError, code: 'OPENCODE_AGENT_REGISTRY_PENDING', promptSent: false, terminal: true, retryable: false, runtimeGeneration: 1, }, 409); } 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 }); } if (path === '/api/opencode/skills') return respond({ skills: [] }); throw new Error(`Unexpected hostapi request: ${method} ${path}`); }); }); } async function capturedRequests(electronApp: ElectronApplication): Promise { return await electronApp.evaluate(() => { const mainGlobal = globalThis as typeof globalThis & { __makeloreMultichatE2EState?: { captured: CapturedRequest[] }; }; return structuredClone(mainGlobal.__makeloreMultichatE2EState?.captured ?? []); }); } function sessionSelectionButton(page: Page, title: string) { return page.getByRole('button', { name: `归档会话 ${title}`, exact: true }) .locator('..') .getByRole('button') .first(); } async function submitComposer(page: Page, text: string): Promise { const composer = page.getByRole('textbox'); await composer.fill(text); const sendButton = page.getByRole('button', { name: '发送', exact: true }); await expect(sendButton).toBeEnabled(); await sendButton.click(); } test('keeps Session A running when Session B is terminally rejected by Agent preflight', async ({ launchElectronApp, }) => { const electronApp = await launchElectronApp({ skipSetup: true }); await installMultichatHost(electronApp); let page = await getStableWindow(electronApp); await disableRendererEventSource(page); await page.reload(); page = await getStableWindow(electronApp); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); await page.getByTestId('ai-module-option-programming').click(); await page.getByTestId('project-agent-chat-game-development').click(); const sessionA = sessionSelectionButton(page, 'Session A'); const sessionB = sessionSelectionButton(page, 'Session B'); await expect(sessionA).toBeVisible(); await expect(sessionB).toBeVisible(); await sessionA.click(); await expect(sessionA).toHaveAttribute('aria-current', 'page'); await submitComposer(page, 'Keep Session A running'); await expect.poll(async () => [...new Set((await capturedRequests(electronApp)) .filter((request) => request.path.includes('/messages')) .map((request) => `${request.method} ${request.path}`))]) .toContain(`POST /api/opencode/sessions/${SESSION_A_ID}/messages`); const stopButton = page.getByRole('button', { name: '停止当前对话' }); await expect(stopButton).toBeEnabled(); await expect(stopButton.getByTestId('opencode-agent-response-loader')).toBeVisible(); await sessionB.click(); await submitComposer(page, 'Try Session B while A is busy'); const errorBanner = page.getByTestId('opencode-error-banner'); await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR); await expect(page.getByRole('textbox')).toHaveValue('Try Session B while A is busy'); await expect(page.getByRole('button', { name: '停止当前对话' })).toHaveCount(0); await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle'); await expect.poll(async () => (await capturedRequests(electronApp)).map( (request) => `${request.method} ${request.path}`, )).toContain(`POST /api/opencode/sessions/${SESSION_B_ID}/messages`); // A rejected preflight deliberately keeps B's draft. Clear it so A's busy // composer projects the stop control and its response loader. await page.getByRole('textbox').fill(''); await sessionA.click(); await expect(errorBanner).toHaveCount(0); await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'busy'); await expect(page.getByRole('button', { name: '停止当前对话' })).toBeEnabled(); await expect(page.getByTestId('opencode-agent-response-loader')).toBeVisible(); await sessionB.click(); await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR); await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle'); const promptPosts = (await capturedRequests(electronApp)).filter((request) => ( request.method === 'POST' && request.path.endsWith('/messages') )); expect(promptPosts.filter((request) => request.path.includes(SESSION_A_ID))).toHaveLength(1); expect(promptPosts.filter((request) => request.path.includes(SESSION_B_ID))).toHaveLength(1); });