mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): settle UI state after stop fallback
This commit is contained in:
parent
7406fc99bf
commit
c59b6b6448
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user