fix: close PI core chat review gaps
This commit is contained in:
@@ -5,9 +5,16 @@ type CapturedRequest = {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
byteLength?: number;
|
||||
contentType?: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
type HostConnection = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
async function disableCodingEventSource(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
class LocalEventSource extends EventTarget {
|
||||
@@ -43,8 +50,11 @@ async function disableCodingEventSource(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function installCodingFirstChatHost(electronApp: ElectronApplication): Promise<void> {
|
||||
await electronApp.evaluate(async () => {
|
||||
async function installCodingFirstChatHost(
|
||||
electronApp: ElectronApplication,
|
||||
hostConnection: HostConnection,
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(async (_, connection) => {
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
type MainState = {
|
||||
captured: CapturedRequest[];
|
||||
@@ -148,14 +158,71 @@ async function installCodingFirstChatHost(electronApp: ElectronApplication): Pro
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (
|
||||
_event,
|
||||
request: { path?: string; method?: string; body?: unknown },
|
||||
request: {
|
||||
path?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
},
|
||||
) => {
|
||||
const path = request.path ?? '';
|
||||
const method = request.method ?? 'GET';
|
||||
const body = typeof request.body === 'string' && request.body
|
||||
? JSON.parse(request.body) as Record<string, unknown>
|
||||
: undefined;
|
||||
state.captured.push({ path, method, ...(body ? { body } : {}), at: Date.now() });
|
||||
const binaryBody = request.body instanceof ArrayBuffer
|
||||
? new Uint8Array(request.body)
|
||||
: ArrayBuffer.isView(request.body)
|
||||
? new Uint8Array(
|
||||
request.body.buffer,
|
||||
request.body.byteOffset,
|
||||
request.body.byteLength,
|
||||
)
|
||||
: undefined;
|
||||
state.captured.push({
|
||||
path,
|
||||
method,
|
||||
...(body ? { body } : {}),
|
||||
...(binaryBody ? { byteLength: binaryBody.byteLength } : {}),
|
||||
...(request.headers?.['Content-Type']
|
||||
? { contentType: request.headers['Content-Type'] }
|
||||
: {}),
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
if (path === '/api/coding/attachments'
|
||||
|| /^\/api\/coding\/attachments\/[^/]+\/content$/.test(path)) {
|
||||
const response = await fetch(`${connection.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
...(request.headers ?? {}),
|
||||
},
|
||||
...(binaryBody ? { body: binaryBody } : {}),
|
||||
});
|
||||
const responseContentType = response.headers.get('content-type') ?? '';
|
||||
if (responseContentType.includes('application/json')) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
json: await response.json(),
|
||||
transport: 'loopback',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||
contentType: responseContentType.split(';', 1)[0]?.trim(),
|
||||
transport: 'loopback',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/coding/projects') {
|
||||
return respond({ projects: [project], activeProjectId: project.id });
|
||||
@@ -205,7 +272,7 @@ async function installCodingFirstChatHost(electronApp: ElectronApplication): Pro
|
||||
}
|
||||
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
|
||||
});
|
||||
});
|
||||
}, hostConnection);
|
||||
}
|
||||
|
||||
async function readState(electronApp: ElectronApplication): Promise<{
|
||||
@@ -239,8 +306,12 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
await installCodingFirstChatHost(electronApp);
|
||||
let page = await getStableWindow(electronApp);
|
||||
const hostConnection = await page.evaluate(async () => ({
|
||||
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||
}));
|
||||
await installCodingFirstChatHost(electronApp, hostConnection);
|
||||
await disableCodingEventSource(page);
|
||||
|
||||
try {
|
||||
@@ -260,6 +331,15 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
|
||||
));
|
||||
expect(editableMs).toBeLessThan(500);
|
||||
|
||||
const pixelPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
await page.getByTestId('coding-file-attachment-input').setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: pixelPng,
|
||||
});
|
||||
await composer.fill('Build the first PI scene');
|
||||
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
await page.getByTestId('coding-message-composer').evaluate(
|
||||
@@ -279,7 +359,28 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
|
||||
await expect(
|
||||
page.getByTestId('coding-conversation-timeline').getByText('Build the first PI scene'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: '对话图片附件' })).toBeVisible();
|
||||
await expect(page.getByText('1 条消息已被本地 Agent 接收。')).toBeVisible();
|
||||
const state = await readState(electronApp);
|
||||
const uploads = state.captured.filter((request) => (
|
||||
request.path === '/api/coding/attachments' && request.method === 'POST'
|
||||
));
|
||||
expect(uploads).toHaveLength(1);
|
||||
expect(uploads[0]).toMatchObject({
|
||||
byteLength: pixelPng.byteLength,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(state.captured.some((request) => (
|
||||
/^\/api\/coding\/attachments\/[^/]+\/content$/.test(request.path)
|
||||
&& request.method === 'GET'
|
||||
))).toBe(true);
|
||||
const prompt = state.captured.find((request) => (
|
||||
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
|
||||
));
|
||||
expect(prompt?.body?.attachments).toEqual([
|
||||
{ attachmentId: expect.any(String) },
|
||||
]);
|
||||
expect(JSON.stringify(state.captured)).not.toContain(pixelPng.toString('base64'));
|
||||
} finally {
|
||||
await releaseSnapshot(electronApp);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { handleCodingAttachmentRoutes } from '../../electron/api/routes/coding-attachments';
|
||||
import { getHostApiToken, startHostApiServer } from '../../electron/api/server';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
|
||||
@@ -36,6 +38,29 @@ async function startAttachmentServer(maxBytes = 16 * 1024 * 1024) {
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => await new Promise<void>((resolve, reject) => {
|
||||
server.closeAllConnections();
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function startAuthenticatedHostServer() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-host-attachments-'));
|
||||
roots.push(root);
|
||||
const attachments = new CodingAttachmentStore(root, { createId: () => 'host-attachment-1' });
|
||||
const context = {
|
||||
opencodeManager: { getStatus: () => ({ url: null }) },
|
||||
codingProducts: { attachments },
|
||||
} as unknown as HostApiContext;
|
||||
const server = startHostApiServer(context, 0);
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Host API test server failed');
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
token: getHostApiToken(),
|
||||
close: async () => await new Promise<void>((resolve, reject) => {
|
||||
server.closeAllConnections();
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
@@ -66,10 +91,43 @@ async function postDeclaredLength(
|
||||
}
|
||||
|
||||
describe('coding attachment routes', () => {
|
||||
it('accepts authenticated binary uploads through the production Host API gate', async () => {
|
||||
const server = await startAuthenticatedHostServer();
|
||||
try {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
|
||||
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${server.token}`,
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
expect(upload.status).toBe(201);
|
||||
await expect(upload.json()).resolves.toEqual({
|
||||
attachmentId: 'host-attachment-1',
|
||||
mime: 'image/png',
|
||||
byteLength: bytes.byteLength,
|
||||
});
|
||||
|
||||
const unrelatedMutation = await fetch(`${server.baseUrl}/api/coding/projects`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${server.token}`,
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
expect(unrelatedMutation.status).toBe(415);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips bounded image bytes through attachment ids', async () => {
|
||||
const server = await startAttachmentServer();
|
||||
try {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
|
||||
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
@@ -119,6 +177,42 @@ describe('coding attachment routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects bytes that do not match the declared image MIME', async () => {
|
||||
const server = await startAttachmentServer();
|
||||
try {
|
||||
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
body: 'not actually a png',
|
||||
});
|
||||
expect(upload.status).toBe(400);
|
||||
await expect(upload.json()).resolves.toMatchObject({
|
||||
code: 'CODING_ATTACHMENT_INVALID',
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['image/jpeg', new Uint8Array([0xff, 0xd8, 0xff, 0x01])],
|
||||
['image/gif', new TextEncoder().encode('GIF89a!')],
|
||||
['image/webp', new TextEncoder().encode('RIFF\x04\x00\x00\x00WEBP')],
|
||||
])('accepts the minimal %s image signature', async (mime, bytes) => {
|
||||
const server = await startAttachmentServer();
|
||||
try {
|
||||
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': mime },
|
||||
body: bytes,
|
||||
});
|
||||
expect(upload.status).toBe(201);
|
||||
await expect(upload.json()).resolves.toMatchObject({ mime });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a declared body larger than the route limit', async () => {
|
||||
const server = await startAttachmentServer();
|
||||
try {
|
||||
@@ -134,4 +228,28 @@ describe('coding attachment routes', () => {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps empty bodies and invalid opaque ids to stable client errors', async () => {
|
||||
const server = await startAttachmentServer();
|
||||
try {
|
||||
const empty = await fetch(`${server.baseUrl}/api/coding/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
expect(empty.status).toBe(400);
|
||||
await expect(empty.json()).resolves.toMatchObject({
|
||||
code: 'CODING_ATTACHMENT_INVALID',
|
||||
});
|
||||
|
||||
for (const id of ['bad%2Fid', '%ZZ']) {
|
||||
const invalid = await fetch(`${server.baseUrl}/api/coding/attachments/${id}/content`);
|
||||
expect(invalid.status).toBe(400);
|
||||
await expect(invalid.json()).resolves.toMatchObject({
|
||||
code: 'CODING_ATTACHMENT_INVALID',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||
import type {
|
||||
CodingConversationMetadata,
|
||||
@@ -58,7 +58,7 @@ const agent: CodingProjectAgent = {
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
model: null,
|
||||
modelResolution: 'default',
|
||||
modelResolution: 'required',
|
||||
};
|
||||
const config: CodingProjectConfig = {
|
||||
schemaVersion: 2,
|
||||
@@ -79,8 +79,24 @@ const conversation: CodingConversationMetadata = {
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
model: null,
|
||||
modelResolution: 'default',
|
||||
modelResolution: 'required',
|
||||
};
|
||||
const reviewer: CodingProjectAgent = {
|
||||
...agent,
|
||||
id: 'agent-2',
|
||||
name: 'Reviewer',
|
||||
pinned: false,
|
||||
};
|
||||
const reviewerConversation: CodingConversationMetadata = {
|
||||
...conversation,
|
||||
id: 'conversation-2',
|
||||
agentId: reviewer.id,
|
||||
title: 'Reviewer conversation',
|
||||
};
|
||||
|
||||
function configForAgents(agents: CodingProjectAgent[]): CodingProjectConfig {
|
||||
return { ...config, agents };
|
||||
}
|
||||
|
||||
const projectApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -94,6 +110,7 @@ const conversationApi = vi.hoisted(() => ({
|
||||
submit: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
}));
|
||||
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/coding-projects', () => ({
|
||||
listCodingProjects: projectApi.list,
|
||||
@@ -109,8 +126,28 @@ vi.mock('@/lib/coding-conversations', () => ({
|
||||
recoverCodingConversation: conversationApi.recover,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/coding-attachments')>(),
|
||||
uploadCodingAttachment: attachmentApi.upload,
|
||||
}));
|
||||
|
||||
describe('CodingChatPanel first Conversation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((file: File) => `blob:${file.name}`),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
@@ -166,6 +203,168 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
|
||||
const slowCreate = deferred<CodingConversationMetadata>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([reviewerConversation]);
|
||||
projectApi.create.mockReturnValue(slowCreate.promise);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
agentId: agent.id,
|
||||
title: '新对话',
|
||||
}));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(reviewerConversation.id));
|
||||
|
||||
await act(async () => {
|
||||
slowCreate.resolve(conversation);
|
||||
await slowCreate.promise;
|
||||
});
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
||||
});
|
||||
|
||||
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
|
||||
const pendingSubmit = deferred<never>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
const textbox = await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
fireEvent.change(textbox, { target: { value: 'A prompt' } });
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().draftsByConversationId[conversation.id]?.text,
|
||||
).toBe('A prompt'));
|
||||
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
|
||||
.toMatchObject({ loadState: 'live', error: null });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
|
||||
await act(async () => {
|
||||
pendingSubmit.reject(new Error('A submission failed'));
|
||||
await pendingSubmit.promise.catch(() => undefined);
|
||||
});
|
||||
expect(screen.queryByText('A submission failed')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('B prompt');
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('caps one message at 16 images and uploads at most four concurrently', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.submit.mockImplementation(async (input: {
|
||||
conversationId: string;
|
||||
clientRequestId: string;
|
||||
mode: 'prompt';
|
||||
}) => ({
|
||||
accepted: true,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: 'run-1',
|
||||
mode: input.mode,
|
||||
}));
|
||||
const uploadFlights: Array<ReturnType<typeof deferred<{
|
||||
attachmentId: string;
|
||||
mime: string;
|
||||
byteLength: number;
|
||||
}>>> = [];
|
||||
let activeUploads = 0;
|
||||
let maxActiveUploads = 0;
|
||||
attachmentApi.upload.mockImplementation((file: File) => {
|
||||
const flight = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
|
||||
uploadFlights.push(flight);
|
||||
activeUploads += 1;
|
||||
maxActiveUploads = Math.max(maxActiveUploads, activeUploads);
|
||||
return flight.promise.finally(() => {
|
||||
activeUploads -= 1;
|
||||
}).then(() => ({
|
||||
attachmentId: `attachment-${file.name}`,
|
||||
mime: file.type,
|
||||
byteLength: file.size,
|
||||
}));
|
||||
});
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
const files = Array.from({ length: 17 }, (_, index) => new File(
|
||||
[new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, index])],
|
||||
`image-${index}.png`,
|
||||
{ type: 'image/png' },
|
||||
));
|
||||
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
|
||||
target: { files },
|
||||
});
|
||||
expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('img')).toHaveLength(16);
|
||||
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
|
||||
.toMatchObject({ loadState: 'live', error: null });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(4));
|
||||
|
||||
let resolved = 0;
|
||||
while (resolved < 16) {
|
||||
const available = uploadFlights.slice(resolved);
|
||||
for (const flight of available) flight.resolve({
|
||||
attachmentId: `resolved-${resolved++}`,
|
||||
mime: 'image/png',
|
||||
byteLength: 9,
|
||||
});
|
||||
if (resolved < 16) {
|
||||
await waitFor(() => expect(uploadFlights.length).toBeGreaterThan(resolved));
|
||||
}
|
||||
}
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
expect(attachmentApi.upload).toHaveBeenCalledTimes(16);
|
||||
expect(maxActiveUploads).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
|
||||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
235
tests/unit/coding-chat-pressure.test.tsx
Normal file
235
tests/unit/coding-chat-pressure.test.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Profiler } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations';
|
||||
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
||||
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createLocalCodingProject,
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
|
||||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||||
import type {
|
||||
CodingConversationPatchBatchEvent,
|
||||
CodingConversationSnapshotEvent,
|
||||
ConversationPatch,
|
||||
ConversationPatchEnvelope,
|
||||
} from '../../electron/coding-runtime/contracts';
|
||||
import { CodingConversationTimeline } from '../../src/pages/Chat/CodingConversationTimeline';
|
||||
import { codingConversationStore } from '../../src/stores/coding-conversations';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function percentile95(values: number[]): number {
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.ceil(ordered.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function pressurePatch(seq: number): ConversationPatch {
|
||||
if (seq === 1) {
|
||||
return {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'assistant-pressure',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [
|
||||
{ kind: 'text', id: 'answer-pressure', text: 'Mixed answer', status: 'complete' },
|
||||
{ kind: 'thinking', id: 'thinking-pressure', text: '', status: 'streaming' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (seq === 2) {
|
||||
return {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-pressure',
|
||||
toolCallId: 'call-pressure',
|
||||
toolName: 'read',
|
||||
title: 'Pressure fixture tool output',
|
||||
inputText: 'fixture.txt',
|
||||
status: 'complete',
|
||||
output: [{
|
||||
kind: 'text',
|
||||
id: 'tool-pressure-output',
|
||||
text: 'T'.repeat(4 * 1024),
|
||||
status: 'complete',
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: 'message.block-delta',
|
||||
messageId: 'assistant-pressure',
|
||||
blockId: 'thinking-pressure',
|
||||
delta: `${seq}:`.padEnd(1_100, 'x'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('REN-008 coding timeline pressure', () => {
|
||||
it('batches 100 KB of mixed output and keeps Main-to-React p95 within 50 ms', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-pressure-'));
|
||||
roots.push(root);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-pressure',
|
||||
now: () => '2026-08-24T00:00:00.000Z',
|
||||
});
|
||||
await createLocalCodingProject({
|
||||
projectPath: root,
|
||||
now: '2026-08-24T00:00:00.000Z',
|
||||
}, projectStore);
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: {
|
||||
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
}, { now: '2026-08-24T00:00:00.000Z' });
|
||||
const runtime = new InMemoryConversationRuntime();
|
||||
const projects = new CodingProjectService(projectStore);
|
||||
const conversations = new CodingConversationService(projects, runtime, {
|
||||
deliveryBatchWindowMs: 24,
|
||||
});
|
||||
const conversation = await conversations.createConversation({
|
||||
agentId: 'builder',
|
||||
title: 'Pressure',
|
||||
});
|
||||
let publish: ((event: ConversationPatchEnvelope) => void) | undefined;
|
||||
vi.spyOn(runtime, 'subscribe').mockImplementation((listener) => {
|
||||
publish = listener;
|
||||
return () => undefined;
|
||||
});
|
||||
const context = {
|
||||
codingProducts: { projects, conversations, runtime },
|
||||
} as unknown as HostApiContext;
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
void handleCodingConversationRoutes(request, response, url, context);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Pressure server did not bind');
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Pressure SSE response has no body');
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffered = '';
|
||||
let wireBytes = 0;
|
||||
let sseFrames = 0;
|
||||
const nextFrame = async (): Promise<{ event: string; data: unknown }> => {
|
||||
while (!buffered.includes('\n\n')) {
|
||||
const next = await reader.read();
|
||||
if (next.done) throw new Error('Pressure SSE ended early');
|
||||
buffered += decoder.decode(next.value, { stream: true });
|
||||
}
|
||||
const boundary = buffered.indexOf('\n\n');
|
||||
const frame = buffered.slice(0, boundary);
|
||||
buffered = buffered.slice(boundary + 2);
|
||||
wireBytes += encoder.encode(`${frame}\n\n`).byteLength;
|
||||
sseFrames += 1;
|
||||
const event = frame.split('\n').find((line) => line.startsWith('event: '))?.slice(7) ?? '';
|
||||
const data = frame.split('\n').find((line) => line.startsWith('data: '))?.slice(6) ?? 'null';
|
||||
return { event, data: JSON.parse(data) as unknown };
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await nextFrame();
|
||||
expect(initial.event).toBe('snapshot');
|
||||
codingConversationStore.getState().applySnapshotEvent(
|
||||
initial.data as CodingConversationSnapshotEvent,
|
||||
);
|
||||
let rendererTransactions = 0;
|
||||
const unsubscribe = codingConversationStore.subscribe(() => {
|
||||
rendererTransactions += 1;
|
||||
});
|
||||
let reactCommits = 0;
|
||||
const view = render(
|
||||
<Profiler id="pressure-timeline" onRender={() => { reactCommits += 1; }}>
|
||||
<CodingConversationTimeline conversationId={conversation.id} />
|
||||
</Profiler>,
|
||||
);
|
||||
const initialCommits = reactCommits;
|
||||
if (!publish) throw new Error('Pressure runtime subscriber was not installed');
|
||||
const latencies: number[] = [];
|
||||
let runtimePatchItems = 0;
|
||||
let patchBatches = 0;
|
||||
for (let burst = 0; burst < 20; burst += 1) {
|
||||
const startedAt = performance.now();
|
||||
for (let offset = 0; offset < 5; offset += 1) {
|
||||
const seq = burst * 5 + offset + 1;
|
||||
runtimePatchItems += 1;
|
||||
publish({
|
||||
conversationId: conversation.id,
|
||||
workerGeneration: 0,
|
||||
seq,
|
||||
at: Date.now(),
|
||||
patch: pressurePatch(seq),
|
||||
});
|
||||
}
|
||||
const frame = await nextFrame();
|
||||
expect(frame.event).toBe('patch-batch');
|
||||
const batch = frame.data as CodingConversationPatchBatchEvent;
|
||||
patchBatches += 1;
|
||||
expect(batch.items).toHaveLength(5);
|
||||
await act(async () => {
|
||||
codingConversationStore.getState().applyPatchBatchEvent(batch);
|
||||
});
|
||||
latencies.push(performance.now() - startedAt);
|
||||
}
|
||||
const measuredReactCommits = reactCommits - initialCommits;
|
||||
const p95Ms = percentile95(latencies);
|
||||
const metrics = {
|
||||
runtimePatchItems,
|
||||
patchBatches,
|
||||
sseFrames,
|
||||
rendererTransactions,
|
||||
reactCommits: measuredReactCommits,
|
||||
wireBytes,
|
||||
mainToReactP95Ms: p95Ms,
|
||||
};
|
||||
console.info('REN-008 metrics', metrics);
|
||||
expect(metrics.runtimePatchItems).toBe(100);
|
||||
expect(metrics.wireBytes).toBeGreaterThan(100 * 1024);
|
||||
expect(metrics.patchBatches).toBeLessThan(metrics.runtimePatchItems);
|
||||
expect(metrics.rendererTransactions).toBe(metrics.patchBatches);
|
||||
expect(metrics.reactCommits).toBeLessThan(metrics.runtimePatchItems);
|
||||
expect(metrics.sseFrames).toBe(metrics.patchBatches + 1);
|
||||
expect(metrics.mainToReactP95Ms).toBeLessThanOrEqual(50);
|
||||
expect(codingConversationStore.getState()
|
||||
.entriesByConversationId[conversation.id]?.reducer.snapshot?.cursor.seq).toBe(100);
|
||||
view.unmount();
|
||||
unsubscribe();
|
||||
} finally {
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -705,6 +705,74 @@ describe('coding Conversation store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes a target Snapshot once more when the first recovery response is still behind', async () => {
|
||||
const firstRecovery = deferred<ConversationSnapshot>();
|
||||
const secondRecovery = deferred<ConversationSnapshot>();
|
||||
const getSnapshot = vi.fn()
|
||||
.mockImplementationOnce(() => firstRecovery.promise)
|
||||
.mockImplementationOnce(() => secondRecovery.promise);
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot,
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 3, [
|
||||
{ op: 'run.state', run: { status: 'running', runId: 'run-a' } },
|
||||
{
|
||||
op: 'context.replace',
|
||||
context: { usedTokens: 20, contextWindow: 100, compaction: 'idle' },
|
||||
},
|
||||
]));
|
||||
|
||||
firstRecovery.resolve(snapshot('conversation-a', 1, 1));
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2));
|
||||
expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('recovering');
|
||||
|
||||
secondRecovery.resolve(snapshot('conversation-a', 1, 2));
|
||||
await waitFor(() => {
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState()))
|
||||
.toMatchObject({
|
||||
cursor: { workerGeneration: 1, seq: 4 },
|
||||
context: { usedTokens: 20, contextWindow: 100 },
|
||||
run: { status: 'running', runId: 'run-a' },
|
||||
});
|
||||
});
|
||||
expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live');
|
||||
});
|
||||
|
||||
it('returns a repeatedly stale recovery Snapshot to a retryable error state', async () => {
|
||||
const firstRecovery = deferred<ConversationSnapshot>();
|
||||
const secondRecovery = deferred<ConversationSnapshot>();
|
||||
const getSnapshot = vi.fn()
|
||||
.mockImplementationOnce(() => firstRecovery.promise)
|
||||
.mockImplementationOnce(() => secondRecovery.promise);
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot,
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 3, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running', runId: 'run-a' },
|
||||
}));
|
||||
|
||||
firstRecovery.resolve(snapshot('conversation-a', 1, 1));
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2));
|
||||
secondRecovery.resolve(snapshot('conversation-a', 1, 1));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({
|
||||
loadState: 'error',
|
||||
error: 'Conversation patch batch is not continuous',
|
||||
});
|
||||
});
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rerender a selected timeline when a hidden Conversation streams', () => {
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
|
||||
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
||||
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
@@ -170,6 +171,74 @@ describe('Pi session registry', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('shares one project mutation queue with ProjectService metadata writes', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-shared-conversations-'));
|
||||
roots.push(projectPath);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-shared',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-a',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Agent A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
}, { now: NOW });
|
||||
const conversationIds = [
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d484',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d485',
|
||||
];
|
||||
const sharedStore = createCodingConversationStore(projectPath, {
|
||||
createId: () => conversationIds.shift() as string,
|
||||
now: () => NOW,
|
||||
});
|
||||
const left = await sharedStore.create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Left',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const sharedProvider = vi.fn(() => sharedStore);
|
||||
const projects = new CodingProjectService(projectStore, {
|
||||
createConversationStore: sharedProvider,
|
||||
});
|
||||
const registry = new PiSessionRegistry({
|
||||
projectStore,
|
||||
createConversationStore: sharedProvider,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
registry.ensureBinding({
|
||||
conversationId: left.id,
|
||||
projectId: 'project-shared',
|
||||
agentId: 'agent-a',
|
||||
title: left.title,
|
||||
model: { model: left.model, modelResolution: left.modelResolution },
|
||||
}, async () => ({ piSessionId: 'pi-left', sessionKey: 'key-left' })),
|
||||
projects.conversationStore(projectPath).create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Right',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
}),
|
||||
]);
|
||||
|
||||
const persisted = await sharedStore.read();
|
||||
expect(projects.conversationStore(projectPath)).toBe(sharedStore);
|
||||
expect(persisted.conversations).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: left.id,
|
||||
piSessionId: 'pi-left',
|
||||
sessionKey: 'key-left',
|
||||
}),
|
||||
expect.objectContaining({ title: 'Right', agentId: 'agent-a' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('maps first session binding persistence failure to the stable storage error', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-registry-binding-failure-'));
|
||||
roots.push(projectPath);
|
||||
|
||||
Reference in New Issue
Block a user