From c59b6b644851388bdc522ade7a32086359288c40 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Tue, 8 Sep 2026 21:39:08 +0800 Subject: [PATCH] fix(chat): settle UI state after stop fallback --- .../chat/__tests__/stopFallback.test.ts | 72 +++++++++++++++++++ mateclaw-ui/src/composables/chat/useChat.ts | 50 ++++++++----- 2 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 mateclaw-ui/src/composables/chat/__tests__/stopFallback.test.ts diff --git a/mateclaw-ui/src/composables/chat/__tests__/stopFallback.test.ts b/mateclaw-ui/src/composables/chat/__tests__/stopFallback.test.ts new file mode 100644 index 00000000..33d470d7 --- /dev/null +++ b/mateclaw-ui/src/composables/chat/__tests__/stopFallback.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const streamMock = vi.hoisted(() => { + const handlers = new Map void>>() + return { + handlers, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + resetDedup: vi.fn(), + on: vi.fn((event: string, handler: (data?: any) => void) => { + const listeners = handlers.get(event) ?? new Set() + listeners.add(handler) + handlers.set(event, listeners) + return () => listeners.delete(handler) + }), + } +}) + +vi.mock('../useStream', () => ({ + useStream: () => streamMock, +})) + +import { useChat } from '../useChat' + +describe('useChat stop fallback', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + streamMock.handlers.clear() + setActivePinia(createPinia()) + localStorage.clear() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('leaves interrupting and freezes running segments when done never arrives', async () => { + const onStreamEnd = vi.fn() + const chat = useChat({ baseUrl: '', onStreamEnd }) + + await chat.sendMessage('run a long task', { + conversationId: 'conv-stop-fallback', + agentId: 'agent-1', + }) + + streamMock.handlers.get('thinking_delta')?.forEach(handler => handler({ delta: 'working' })) + expect(chat.isGenerating.value).toBe(true) + + chat.stopGeneration() + expect(chat.streamPhase.value).toBe('interrupting') + + await vi.advanceTimersByTimeAsync(3000) + + expect(chat.streamPhase.value).toBe('stopped') + expect(chat.isGenerating.value).toBe(false) + expect(chat.messages.value.at(-1)?.status).toBe('stopped') + expect((chat.messages.value.at(-1)?.metadata as any)?.segments?.[0]).toMatchObject({ + status: 'completed', + thinkingText: 'working', + }) + expect(streamMock.disconnect).toHaveBeenCalled() + expect(onStreamEnd).toHaveBeenCalledWith({ + conversationId: 'conv-stop-fallback', + reason: 'stopped', + }) + }) +}) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 0aa7691d..f9c8e325 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -2146,6 +2146,36 @@ export function useChat(options: UseChatOptions): UseChatReturn { // backend to return a 'done' event. This ensures onStreamEnd fires and message/conversation // state is updated correctly. // A 3-second fallback timeout guards against 'done' never arriving due to network issues. + const finalizeStoppedLocally = (convId: string, assistantId: string | null) => { + // A fallback from an older turn must never tear down a newer conversation. + if (streamConversationId !== convId && streamConversationId) return + + stream.disconnect() + + if (currentAssistantId.value === assistantId && assistantId) { + const stoppedAt = Date.now() + currentSegments.value.forEach((segment: MessageSegment) => { + if (segment.status === 'running') { + segment.status = 'completed' + segment.endTimestamp ??= stoppedAt + } + }) + setMessageStatus(assistantId, 'stopped') + // Persist the frozen segment snapshot before dropping the active id; + // otherwise thinking/tool/delegation rows keep animating forever. + flushSegmentsToMessage(true) + currentAssistantId.value = null + } + + streamPhase.value = 'stopped' + phaseInfo.value = null + compactStatus.value = null + lifecycleStage.value = null + messageQueue.clear() + expirePendingApprovals('stopped') + onStreamEnd?.({ conversationId: convId, reason: 'stopped' }) + } + const stopGeneration = async () => { // Freeze identifiers and install the fallback timer before any await, so a concurrent // resetForNewConversation cannot clear context out from under us. @@ -2178,18 +2208,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { stopFallbackTimer = setTimeout(() => { stopFallbackTimer = null console.warn('[useChat] Stop fallback: done event not received within 3s, force cleanup') - // Only disconnect if the stream still belongs to the old conversation — avoids killing a new session's stream - if (streamConversationId === convId || !streamConversationId) { - stream.disconnect() - } - if (currentAssistantId.value === assistantId && assistantId) { - setMessageStatus(assistantId, 'stopped') - currentAssistantId.value = null - } - onStreamEnd?.({ - conversationId: convId, - reason: 'stopped', - }) + finalizeStoppedLocally(convId, assistantId) }, 3000) // Cancel the fallback timer when the done/error event arrives @@ -2215,12 +2234,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { clearTimeout(stopFallbackTimer) stopFallbackTimer = setTimeout(() => { stopFallbackTimer = null - if (streamConversationId === convId || !streamConversationId) stream.disconnect() - if (currentAssistantId.value === assistantId && assistantId) { - setMessageStatus(assistantId, 'stopped') - currentAssistantId.value = null - } - onStreamEnd?.({ conversationId: convId, reason: 'stopped' }) + finalizeStoppedLocally(convId, assistantId) }, 250) } })