fix(coding): rehydrate selected conversation on foreground

This commit is contained in:
inman
2026-09-03 10:45:12 +08:00
parent 301c1496b0
commit 7aa118b2d3
6 changed files with 437 additions and 20 deletions

View File

@@ -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',

View File

@@ -347,6 +347,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>();