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

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

@@ -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<string, unknown>;
};
async function installFirstChatHost(electronApp: ElectronApplication): Promise<void> {
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<Record<string, unknown>>,
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<string, unknown>
: 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<void>((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<CapturedRequest[]> {
return await electronApp.evaluate(() => {
const mainGlobal = globalThis as typeof globalThis & {
__makeloreFirstChatE2EState?: { captured: CapturedRequest[] };
};
return structuredClone(mainGlobal.__makeloreFirstChatE2EState?.captured ?? []);
});
}
async function releaseInitialHistory(electronApp: ElectronApplication): Promise<void> {
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);
}
});