合并首次会话延迟修复
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind';
|
import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind';
|
||||||
|
import { isOpencodeUpstreamSaturated } from '../../../shared/opencode-error-details';
|
||||||
|
|
||||||
const AI_PROXY_PREFIX = '/api/ai-proxy/v1';
|
const AI_PROXY_PREFIX = '/api/ai-proxy/v1';
|
||||||
const MAX_ERROR_LOG_MESSAGE_LENGTH = 600;
|
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') {
|
if (status === 429 && getOpencodeErrorKind(bodyText) === 'quota_exhausted') {
|
||||||
return 402;
|
return 402;
|
||||||
}
|
}
|
||||||
|
if (status === 429 && isOpencodeUpstreamSaturated(bodyText)) {
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
shared/opencode-error-details.ts
Normal file
6
shared/opencode-error-details.ts
Normal file
@@ -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'));
|
||||||
|
}
|
||||||
@@ -1355,7 +1355,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
}, [authenticationFailureKey, logout]);
|
}, [authenticationFailureKey, logout]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
if (!activeProject || status.state !== 'running') return;
|
||||||
void loadSessionMessages(selectedSessionId).catch(() => undefined);
|
void loadSessionMessages(selectedSessionId).catch(() => undefined);
|
||||||
}, [
|
}, [
|
||||||
@@ -1364,6 +1369,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
selectedSessionRunning,
|
selectedSessionRunning,
|
||||||
sessionMessages.length,
|
sessionMessages.length,
|
||||||
|
sessionMessagesBySessionId,
|
||||||
status.state,
|
status.state,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1718,7 +1724,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDev
|
|||||||
const createdSessionId = getSessionId(session);
|
const createdSessionId = getSessionId(session);
|
||||||
if (!createdSessionId) throw new Error('运行时未返回会话 ID');
|
if (!createdSessionId) throw new Error('运行时未返回会话 ID');
|
||||||
await linkProjectSession(activeProject.id, createdSessionId, submissionAgent.id);
|
await linkProjectSession(activeProject.id, createdSessionId, submissionAgent.id);
|
||||||
await selectSession(createdSessionId);
|
await selectSession(createdSessionId, { loadMessages: false });
|
||||||
submissionSession = {
|
submissionSession = {
|
||||||
id: createdSessionId,
|
id: createdSessionId,
|
||||||
generation: submissionSession.generation + 1,
|
generation: submissionSession.generation + 1,
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ interface OpencodeState {
|
|||||||
deleteSession: (sessionId: string) => Promise<void>;
|
deleteSession: (sessionId: string) => Promise<void>;
|
||||||
abortSession: (sessionId: string) => Promise<void>;
|
abortSession: (sessionId: string) => Promise<void>;
|
||||||
clearSessionSelection: () => void;
|
clearSessionSelection: () => void;
|
||||||
selectSession: (sessionId: string) => Promise<RawMessage[]>;
|
selectSession: (sessionId: string, options?: { loadMessages?: boolean }) => Promise<RawMessage[]>;
|
||||||
loadSessionStatuses: () => Promise<OpencodeSessionStatusMap>;
|
loadSessionStatuses: () => Promise<OpencodeSessionStatusMap>;
|
||||||
loadSessionMessages: (sessionId: string) => Promise<RawMessage[]>;
|
loadSessionMessages: (sessionId: string) => Promise<RawMessage[]>;
|
||||||
sendSessionMessage: (sessionId: string, text: string, options?: SendSessionMessageOptions) => Promise<RawMessage[]>;
|
sendSessionMessage: (sessionId: string, text: string, options?: SendSessionMessageOptions) => Promise<RawMessage[]>;
|
||||||
@@ -3353,7 +3353,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async selectSession(sessionId) {
|
async selectSession(sessionId, options) {
|
||||||
const state = get();
|
const state = get();
|
||||||
sessionDiffLoadSequence += 1;
|
sessionDiffLoadSequence += 1;
|
||||||
const projectId = getProjectId(state.activeProject);
|
const projectId = getProjectId(state.activeProject);
|
||||||
@@ -3373,6 +3373,12 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
|||||||
sessionDiffError: null,
|
sessionDiffError: null,
|
||||||
...errorState(null),
|
...errorState(null),
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
options?.loadMessages === false
|
||||||
|
&& Object.prototype.hasOwnProperty.call(state.sessionMessagesBySessionId, sessionId)
|
||||||
|
) {
|
||||||
|
return state.sessionMessagesBySessionId[sessionId] ?? [];
|
||||||
|
}
|
||||||
return await get().loadSessionMessages(sessionId);
|
return await get().loadSessionMessages(sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
254
tests/e2e/opencode-first-chat.spec.ts
Normal file
254
tests/e2e/opencode-first-chat.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -336,20 +336,21 @@ describe('ai proxy routes', () => {
|
|||||||
expect(response.body()).toContain('works_square_gateway_authorize_failed');
|
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({
|
seedWorksSquareAIGatewayCredential({
|
||||||
accessToken: 'ws-ai-token',
|
accessToken: 'ws-ai-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
oneApiBaseUrl: 'https://one-api.example.com/v1',
|
oneApiBaseUrl: 'https://one-api.example.com/v1',
|
||||||
});
|
});
|
||||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
const body = JSON.stringify({
|
||||||
new Response(JSON.stringify({
|
|
||||||
error: {
|
error: {
|
||||||
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
|
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
|
||||||
code: 'rate_limit_exceeded',
|
code: 'rate_limit_exceeded',
|
||||||
type: 'one_api_error',
|
type: 'one_api_error',
|
||||||
},
|
},
|
||||||
}), {
|
});
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||||
|
new Response(body, {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
}),
|
}),
|
||||||
@@ -364,8 +365,10 @@ describe('ai proxy routes', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(429);
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
expect(response.body()).toContain('当前分组上游负载已饱和');
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.header('content-type')).toContain('application/json');
|
||||||
|
expect(response.body()).toBe(body);
|
||||||
expect(loggerWarnMock).toHaveBeenCalledWith(
|
expect(loggerWarnMock).toHaveBeenCalledWith(
|
||||||
'[ai-proxy] One-api returned non-success response',
|
'[ai-proxy] One-api returned non-success response',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -380,6 +383,40 @@ describe('ai proxy routes', () => {
|
|||||||
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('ws-ai-token');
|
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 () => {
|
it('strips decoded compression headers from proxied responses', async () => {
|
||||||
seedWorksSquareAIGatewayCredential({
|
seedWorksSquareAIGatewayCredential({
|
||||||
accessToken: 'ws-ai-token',
|
accessToken: 'ws-ai-token',
|
||||||
|
|||||||
@@ -1023,6 +1023,60 @@ describe('OpencodeChatPanel', () => {
|
|||||||
expect(hostApiFetchMock).not.toHaveBeenCalledWith('/api/opencode/sessions', expect.objectContaining({ method: 'POST' }));
|
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 () => {
|
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 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();
|
const config = createConfiguredProjectConfig();
|
||||||
|
|||||||
21
tests/unit/opencode-error-details.test.ts
Normal file
21
tests/unit/opencode-error-details.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1251,6 +1251,40 @@ describe('opencode store', () => {
|
|||||||
expect(useOpencodeStore.getState().sessionMessages).toEqual(loaded);
|
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 () => {
|
it('streams assistant updates and tool status before finalizing transcript', async () => {
|
||||||
const { hostApiFetch } = await import('@/lib/host-api');
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
const source = new MockEventSource('/api/opencode/events?sessionId=ses_1');
|
const source = new MockEventSource('/api/opencode/events?sessionId=ses_1');
|
||||||
|
|||||||
Reference in New Issue
Block a user