merge: integrate foreground conversation reconciliation
# Conflicts: # tests/e2e/pi-coding-first-chat.spec.ts
This commit is contained in:
@@ -19,6 +19,11 @@ type HostConnection = {
|
||||
async function disableCodingEventSource(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const sources = new Set<LocalEventSource>();
|
||||
type TrackedWindow = Window & {
|
||||
__makeloreCodingEventSources?: LocalEventSource[];
|
||||
};
|
||||
const trackedWindow = window as TrackedWindow;
|
||||
trackedWindow.__makeloreCodingEventSources = [];
|
||||
class LocalEventSource extends EventTarget {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
@@ -37,6 +42,7 @@ async function disableCodingEventSource(page: Page): Promise<void> {
|
||||
super();
|
||||
this.url = url;
|
||||
sources.add(this);
|
||||
trackedWindow.__makeloreCodingEventSources?.push(this);
|
||||
queueMicrotask(() => this.onopen?.(new Event('open')));
|
||||
}
|
||||
|
||||
@@ -86,6 +92,7 @@ async function installCodingFirstChatHost(
|
||||
abortRequested: boolean;
|
||||
releaseSnapshot: (() => void) | null;
|
||||
snapshotPending: boolean;
|
||||
snapshotSettled: boolean;
|
||||
};
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__makelorePiFirstChatE2E?: MainState;
|
||||
@@ -97,6 +104,7 @@ async function installCodingFirstChatHost(
|
||||
abortRequested: false,
|
||||
releaseSnapshot: null,
|
||||
snapshotPending: false,
|
||||
snapshotSettled: false,
|
||||
};
|
||||
mainGlobal.__makelorePiFirstChatE2E = state;
|
||||
const now = '2026-08-24T00:00:00.000Z';
|
||||
@@ -530,11 +538,28 @@ async function installCodingFirstChatHost(
|
||||
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
|
||||
state.snapshotPending = false;
|
||||
}
|
||||
const currentSnapshot = state.snapshotSettled
|
||||
? {
|
||||
...snapshot,
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-e2e-feature',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
settledAt: 2_000,
|
||||
terminalReason: 'aborted',
|
||||
},
|
||||
queue: { items: [] },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'stopped', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 1 },
|
||||
}
|
||||
: snapshot;
|
||||
return respond({
|
||||
snapshot: state.abortRequested
|
||||
? {
|
||||
...snapshot,
|
||||
nodes: snapshot.nodes.map((node) => {
|
||||
...currentSnapshot,
|
||||
nodes: currentSnapshot.nodes.map((node) => {
|
||||
if (node.kind === 'message' && node.role === 'assistant') {
|
||||
return {
|
||||
...node,
|
||||
@@ -567,8 +592,8 @@ async function installCodingFirstChatHost(
|
||||
cursor: { workerGeneration: 1, seq: 1 },
|
||||
}
|
||||
: state.interactionAnswered
|
||||
? { ...snapshot, pendingInteractions: [] }
|
||||
: snapshot,
|
||||
? { ...currentSnapshot, pendingInteractions: [] }
|
||||
: currentSnapshot,
|
||||
});
|
||||
}
|
||||
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
||||
@@ -652,6 +677,17 @@ async function releaseSnapshot(electronApp: ElectronApplication): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
async function settleSnapshot(electronApp: ElectronApplication): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__makelorePiFirstChatE2E?: { snapshotSettled: boolean };
|
||||
};
|
||||
if (mainGlobal.__makelorePiFirstChatE2E) {
|
||||
mainGlobal.__makelorePiFirstChatE2E.snapshotSettled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
@@ -903,6 +939,60 @@ test('hidden Conversation badge ignores process failures until interaction or ta
|
||||
}
|
||||
});
|
||||
|
||||
test('foreground focus rehydrates a terminal Snapshot after lifecycle sleep', async ({
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
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, true);
|
||||
await disableCodingEventSource(page);
|
||||
|
||||
await page.reload();
|
||||
page = await getStableWindow(electronApp);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
|
||||
const processGroup = page.getByTestId('coding-process-group');
|
||||
await expect(processGroup).toHaveAttribute('data-process-state', 'active');
|
||||
await expect(processGroup.locator('summary').first()).toContainText('处理中');
|
||||
const snapshotPath = '/api/coding/conversations/conversation-pi-first-chat/snapshot';
|
||||
const stateBeforeSleep = await readState(electronApp);
|
||||
const snapshotCallsBeforeSleep = stateBeforeSleep.captured.filter((request) => (
|
||||
request.path === snapshotPath
|
||||
)).length;
|
||||
await settleSnapshot(electronApp);
|
||||
|
||||
await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('lifecycle:sleep');
|
||||
}
|
||||
});
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const trackedWindow = window as typeof window & {
|
||||
__makeloreCodingEventSources?: Array<{ readyState: number }>;
|
||||
};
|
||||
return trackedWindow.__makeloreCodingEventSources?.at(-1)?.readyState ?? -1;
|
||||
})).toBe(2);
|
||||
|
||||
await page.evaluate(() => window.dispatchEvent(new FocusEvent('focus')));
|
||||
|
||||
await expect(processGroup).toHaveAttribute('data-process-state', 'settled');
|
||||
await expect(processGroup.locator('summary').first()).not.toContainText('处理中');
|
||||
await expect.poll(async () => {
|
||||
const state = await readState(electronApp);
|
||||
return state.captured.filter((request) => request.path === snapshotPath).length;
|
||||
}).toBeGreaterThan(snapshotCallsBeforeSleep);
|
||||
const stateAfterFocus = await readState(electronApp);
|
||||
expect(stateAfterFocus.captured.some((request) => (
|
||||
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
|
||||
@@ -144,6 +144,7 @@ const conversationApi = vi.hoisted(() => ({
|
||||
diagnostics: vi.fn(),
|
||||
}));
|
||||
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
||||
const hostEventApi = vi.hoisted(() => ({ subscribe: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/coding-projects', () => ({
|
||||
listCodingProjects: projectApi.list,
|
||||
@@ -175,8 +176,13 @@ vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
|
||||
uploadCodingAttachment: attachmentApi.upload,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-events', () => ({
|
||||
subscribeHostEvent: hostEventApi.subscribe,
|
||||
}));
|
||||
|
||||
describe('CodingChatPanel first Conversation', () => {
|
||||
beforeEach(() => {
|
||||
hostEventApi.subscribe.mockReturnValue(vi.fn());
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((file: File) => `blob:${file.name}`),
|
||||
@@ -231,6 +237,133 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(source.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rehydrates a preselected stale Conversation when the programming view mounts', 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);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
|
||||
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
|
||||
codingWorkspaceStore.setState({
|
||||
activeProjectId: project.id,
|
||||
activeProject: project,
|
||||
config,
|
||||
conversations: [conversation],
|
||||
selectedAgentId: agent.id,
|
||||
});
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: conversation.id,
|
||||
workerGeneration: 1,
|
||||
seq: 4,
|
||||
snapshot: {
|
||||
...localSnapshot,
|
||||
run: {
|
||||
status: 'running',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
},
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 4 },
|
||||
},
|
||||
});
|
||||
codingConversationStore.setState({ selectedConversationId: conversation.id });
|
||||
conversationApi.snapshot.mockResolvedValue({
|
||||
...localSnapshot,
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
settledAt: 2_000,
|
||||
terminalReason: 'aborted',
|
||||
},
|
||||
worker: { status: 'stopped', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 5 },
|
||||
});
|
||||
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run,
|
||||
).toMatchObject({ status: 'idle', terminalReason: 'aborted' }));
|
||||
expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id);
|
||||
expect(screen.queryByText('处理中')).not.toBeInTheDocument();
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rehydrates a stale running Conversation after lifecycle sleep and window focus', async () => {
|
||||
let sleepHandler: (() => void) | null = null;
|
||||
hostEventApi.subscribe.mockImplementation((eventName: string, handler: () => void) => {
|
||||
if (eventName === 'lifecycle:sleep') sleepHandler = handler;
|
||||
return vi.fn();
|
||||
});
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
projectApi.patch.mockResolvedValue(conversation);
|
||||
const firstSource = new FakeEventSource();
|
||||
const resumedSource = new FakeEventSource();
|
||||
conversationApi.events
|
||||
.mockResolvedValueOnce(firstSource as unknown as EventSource)
|
||||
.mockResolvedValueOnce(resumedSource as unknown as EventSource);
|
||||
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');
|
||||
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
|
||||
const runningSnapshot: ConversationSnapshot = {
|
||||
...localSnapshot,
|
||||
run: {
|
||||
status: 'running',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
},
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 4 },
|
||||
};
|
||||
const terminalSnapshot: ConversationSnapshot = {
|
||||
...runningSnapshot,
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
settledAt: 2_000,
|
||||
terminalReason: 'aborted',
|
||||
},
|
||||
worker: { status: 'stopped', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 5 },
|
||||
};
|
||||
conversationApi.snapshot.mockResolvedValue(runningSnapshot);
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status,
|
||||
).toBe('running'));
|
||||
expect(screen.getByText('处理中')).toBeInTheDocument();
|
||||
const snapshotCallsBeforeSleep = conversationApi.snapshot.mock.calls.length;
|
||||
conversationApi.snapshot.mockResolvedValue(terminalSnapshot);
|
||||
|
||||
act(() => sleepHandler?.());
|
||||
expect(firstSource.close).toHaveBeenCalledOnce();
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
|
||||
await waitFor(() => expect(
|
||||
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run,
|
||||
).toMatchObject({ status: 'idle', terminalReason: 'aborted' }));
|
||||
expect(conversationApi.snapshot.mock.calls.length).toBeGreaterThan(snapshotCallsBeforeSleep);
|
||||
expect(conversationApi.events).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByText('处理中')).not.toBeInTheDocument();
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the new core Chat Renderer free of legacy OpenCode imports', async () => {
|
||||
const files = [
|
||||
'../../src/pages/Chat/CodingChatPanel.tsx',
|
||||
|
||||
@@ -486,6 +486,61 @@ describe('coding Conversation store', () => {
|
||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rehydrates a selected live Conversation without replaying an accepted mutation', async () => {
|
||||
const source = new FakeEventSource();
|
||||
const refresh = deferred<ConversationSnapshot>();
|
||||
const staleRunning = {
|
||||
...snapshot('conversation-a', 1, 4),
|
||||
run: {
|
||||
status: 'running',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
} as const,
|
||||
};
|
||||
const persistedTerminal = {
|
||||
...snapshot('conversation-a', 1, 5),
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
startedAt: 1_000,
|
||||
settledAt: 2_000,
|
||||
terminalReason: 'aborted',
|
||||
} as const,
|
||||
};
|
||||
const getSnapshot = vi.fn()
|
||||
.mockResolvedValueOnce(staleRunning)
|
||||
.mockImplementationOnce(() => refresh.promise);
|
||||
const openEvents = vi.fn(async () => source as unknown as EventSource);
|
||||
const submitPrompt = vi.fn();
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot,
|
||||
openEvents,
|
||||
submitPrompt,
|
||||
createId: ids(),
|
||||
});
|
||||
|
||||
await store.getState().selectConversation('conversation-a');
|
||||
const reselection = store.getState().selectConversation('conversation-a');
|
||||
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(openEvents).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({
|
||||
loadState: 'live',
|
||||
error: null,
|
||||
});
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status)
|
||||
.toBe('running');
|
||||
|
||||
refresh.resolve(persistedTerminal);
|
||||
await reselection;
|
||||
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run)
|
||||
.toMatchObject({ status: 'idle', terminalReason: 'aborted' });
|
||||
expect(submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a cancelled connection clear a newer connection flight', async () => {
|
||||
const first = deferred<EventSource>();
|
||||
const second = deferred<EventSource>();
|
||||
|
||||
Reference in New Issue
Block a user