fix(chat): settle UI state after stop fallback

This commit is contained in:
mateaix 2026-09-08 21:39:08 +08:00
parent 7406fc99bf
commit c59b6b6448
2 changed files with 104 additions and 18 deletions

View File

@ -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<string, Set<(data?: any) => 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',
})
})
})

View File

@ -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)
}
})