fix(coding): restore sending after queued messages settle

This commit is contained in:
2026-09-12 12:50:01 +08:00
parent 2cedc9df30
commit 954b275f10
6 changed files with 162 additions and 8 deletions

View File

@@ -1169,6 +1169,67 @@ describe('CodingChatPanel first Conversation', () => {
expect(conversationApi.recover).not.toHaveBeenCalled();
});
it.each([
['follow-up', 'completed'],
['follow-up', 'aborted'],
['steer', 'completed'],
['steer', 'aborted'],
] as const)('sends again after %s and %s settlement', async (mode, terminalReason) => {
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.submit.mockImplementation(async (input) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-1',
mode: input.mode,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const runningSnapshot: ConversationSnapshot = {
...createLocalConversationSnapshot(project.id, conversation),
worker: { status: 'ready', generation: 1 },
run: { status: 'running', runId: 'run-1', mode: 'prompt' },
cursor: { workerGeneration: 1, seq: 1 },
};
conversationApi.snapshot.mockResolvedValue(runningSnapshot);
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await screen.findByRole('button', { name: '中止生成' });
fireEvent.change(textbox, { target: { value: '排队的消息' } });
fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), {
target: { value: mode },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled());
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: conversation.id,
workerGeneration: 1,
seq: 2,
snapshot: {
...runningSnapshot,
cursor: { workerGeneration: 1, seq: 2 },
run: { status: 'idle', runId: 'run-1', mode: 'prompt', terminalReason, settledAt: 2_000 },
},
}));
fireEvent.change(textbox, { target: { value: '继续' } });
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument();
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' });
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
expect(conversationApi.submit).toHaveBeenLastCalledWith(expect.objectContaining({
conversationId: conversation.id,
mode: 'prompt',
text: '继续',
}));
});
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
const onSubmit = vi.fn();

View File

@@ -621,6 +621,28 @@ describe('coding Conversation store', () => {
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
});
it.each(['steer', 'follow-up'] as const)('clears %s acceptance even when settlement precedes the response', async (mode) => {
const pending = deferred<PromptAcceptance>();
const store = createCodingConversationStore({
getSnapshot: vi.fn(),
openEvents: vi.fn(),
submitPrompt: vi.fn(() => pending.promise),
createId: ids(),
});
const running = snapshot('conversation-a');
running.run = { status: 'running', mode: 'prompt', runId: 'run-1' };
store.getState().applySnapshotEvent(snapshotEvent(running));
store.getState().setDraft('conversation-a', '排队的消息');
const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode });
const settled = snapshot('conversation-a', 1, 1);
settled.run = { status: 'idle', mode: 'prompt', runId: 'run-1', terminalReason: 'completed', settledAt: 1_000 };
store.getState().applySnapshotEvent(snapshotEvent(settled));
pending.resolve({ ...acceptance('conversation-a', 'request-1'), mode });
await submission;
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('');
});
it('preserves the optimistic UI id when a reconnect snapshot is already durable', async () => {
const store = createCodingConversationStore({
getSnapshot: vi.fn(),