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
|
// backend to return a 'done' event. This ensures onStreamEnd fires and message/conversation
|
||||||
// state is updated correctly.
|
// state is updated correctly.
|
||||||
// A 3-second fallback timeout guards against 'done' never arriving due to network issues.
|
// 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 () => {
|
const stopGeneration = async () => {
|
||||||
// Freeze identifiers and install the fallback timer before any await, so a concurrent
|
// Freeze identifiers and install the fallback timer before any await, so a concurrent
|
||||||
// resetForNewConversation cannot clear context out from under us.
|
// resetForNewConversation cannot clear context out from under us.
|
||||||
@ -2178,18 +2208,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
stopFallbackTimer = setTimeout(() => {
|
stopFallbackTimer = setTimeout(() => {
|
||||||
stopFallbackTimer = null
|
stopFallbackTimer = null
|
||||||
console.warn('[useChat] Stop fallback: done event not received within 3s, force cleanup')
|
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
|
finalizeStoppedLocally(convId, assistantId)
|
||||||
if (streamConversationId === convId || !streamConversationId) {
|
|
||||||
stream.disconnect()
|
|
||||||
}
|
|
||||||
if (currentAssistantId.value === assistantId && assistantId) {
|
|
||||||
setMessageStatus(assistantId, 'stopped')
|
|
||||||
currentAssistantId.value = null
|
|
||||||
}
|
|
||||||
onStreamEnd?.({
|
|
||||||
conversationId: convId,
|
|
||||||
reason: 'stopped',
|
|
||||||
})
|
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
// Cancel the fallback timer when the done/error event arrives
|
// Cancel the fallback timer when the done/error event arrives
|
||||||
@ -2215,12 +2234,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
clearTimeout(stopFallbackTimer)
|
clearTimeout(stopFallbackTimer)
|
||||||
stopFallbackTimer = setTimeout(() => {
|
stopFallbackTimer = setTimeout(() => {
|
||||||
stopFallbackTimer = null
|
stopFallbackTimer = null
|
||||||
if (streamConversationId === convId || !streamConversationId) stream.disconnect()
|
finalizeStoppedLocally(convId, assistantId)
|
||||||
if (currentAssistantId.value === assistantId && assistantId) {
|
|
||||||
setMessageStatus(assistantId, 'stopped')
|
|
||||||
currentAssistantId.value = null
|
|
||||||
}
|
|
||||||
onStreamEnd?.({ conversationId: convId, reason: 'stopped' })
|
|
||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user