Files
MoviePilot-Frontend/src/components/agent/__tests__/AgentAssistantPanel.spec.ts
2026-08-12 14:35:44 +08:00

1295 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => displayState,
}))
vi.mock('@/stores', () => ({
useAuthStore: () => ({ token: null }),
useUserStore: () => ({ getUserName: 'Tester' }),
}))
vi.mock('@/plugins/i18n', () => ({
getCurrentLocale: () => 'zh-CN',
}))
interface MockServerSession {
session_id: string
client_session_id: string
updated_at: string
is_processing: boolean
messages: Array<Record<string, unknown>>
}
interface SyntheticSseFrame {
eventName?: string
data: unknown
}
const agentMarkdownContentStub = {
props: ['content', 'variant'],
template:
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
}
// 构造符合 Agent 标准响应包装的 fetch 返回值。
function createAgentResponse(data: unknown) {
return {
json: vi.fn().mockResolvedValue({ success: true, data }),
ok: true,
} as unknown as Response
}
function legacySseFrame(data: Record<string, unknown>): SyntheticSseFrame {
return { data }
}
function protectedSseFrame(data: Record<string, unknown>): SyntheticSseFrame {
return { eventName: 'interaction-protected', data }
}
function createAgentStreamResponse(frames: SyntheticSseFrame[]) {
const body = frames
.map(frame => `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`)
.join('')
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
function createRawAgentStreamResponse(body: string) {
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
function createControllableAgentStream() {
const encoder = new TextEncoder()
let streamController: ReadableStreamDefaultController<Uint8Array>
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
)
return {
response,
emit(frame: SyntheticSseFrame) {
const block = `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`
streamController.enqueue(encoder.encode(block))
},
close() {
streamController.close()
},
fail(error: Error) {
streamController.error(error)
},
}
}
const slotContainerStub = { template: '<div><slot /></div>' }
const menuStub = defineComponent({
setup(_props, { slots }) {
return () => h('div', [slots.activator?.({ props: {} }), slots.default?.()])
},
})
const virtualScrollStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () =>
h(
'div',
props.items.map(item => slots.default?.({ item, itemRef: () => undefined })),
)
},
})
function mountPanel() {
return shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VCard: slotContainerStub,
VInfiniteScroll: slotContainerStub,
VIcon: true,
VMenu: menuStub,
VVirtualScroll: virtualScrollStub,
},
},
})
}
describe('AgentAssistantPanel stream recovery', () => {
beforeEach(() => {
vi.useFakeTimers()
localStorage.clear()
})
afterEach(() => {
displayState.mdAndDown.value = true
vi.useRealTimers()
})
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
displayState.mdAndDown.value = false
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
const panel = wrapper.get('.agent-assistant-panel')
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
await fullscreenToggle.trigger('click')
expect(panel.classes()).toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
await fullscreenToggle.trigger('click')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
wrapper.unmount()
})
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
wrapper.unmount()
})
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
const startedAt = Date.now() - 5000
const localSessionId = 'web-local-session'
const serverSessionId = 'web-agent:server-session'
const userMessage = {
id: 'user-1',
role: 'user',
content: '检查后台任务',
createdAt: startedAt - 100,
status: 'done',
attachments: [],
choices: [],
tools: [],
}
const assistantPlaceholder = {
id: 'assistant-1',
role: 'assistant',
content: '',
createdAt: startedAt,
status: 'done',
attachments: [],
choices: [],
tools: [],
}
const processingSession: MockServerSession = {
session_id: serverSessionId,
client_session_id: localSessionId,
updated_at: new Date(startedAt + 1000).toISOString(),
is_processing: true,
messages: [userMessage],
}
const completedSession: MockServerSession = {
...processingSession,
updated_at: new Date(startedAt + 2000).toISOString(),
is_processing: false,
messages: [
userMessage,
{
...assistantPlaceholder,
content: '后台任务已经完成',
createdAt: startedAt + 2000,
status: 'done',
},
],
}
const detailResponses = [processingSession, completedSession]
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes(`/sessions/${localSessionId}`)) {
return createAgentResponse(detailResponses.shift() || completedSession)
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
localStorage.setItem(
'moviepilot-agent-assistant-state',
JSON.stringify({
sessionId: localSessionId,
messages: [userMessage, assistantPlaceholder],
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
expect(wrapper.text()).toContain('agentAssistant.thinking')
expect(
fetchMock.mock.calls.filter(([input]) => String(input).includes(`/sessions/${localSessionId}`)),
).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1200)
await flushPromises()
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
expect(wrapper.text()).toContain('后台任务已经完成')
expect(wrapper.text()).toContain('agentAssistant.ready')
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
sessionId: serverSessionId,
streamRecovery: null,
})
wrapper.unmount()
})
it('persists an active stream in the background and restores it when the PWA becomes visible', async () => {
let visibilityState: DocumentVisibilityState = 'visible'
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
const startedAt = Date.now()
const localSessionId = 'web-local-active'
const serverSessionId = 'web-agent:server-active'
const userMessage = {
id: 'user-active',
role: 'user',
content: '继续后台处理',
createdAt: startedAt,
status: 'done',
attachments: [],
choices: [],
tools: [],
}
const completedSession: MockServerSession = {
session_id: serverSessionId,
client_session_id: localSessionId,
updated_at: new Date(startedAt + 2000).toISOString(),
is_processing: false,
messages: [
userMessage,
{
id: 'assistant-active',
role: 'assistant',
content: '前后台恢复成功',
createdAt: startedAt + 2000,
status: 'done',
attachments: [],
choices: [],
tools: [],
},
],
}
const encoder = new TextEncoder()
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream')) {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'start', session_id: serverSessionId })}\n\n`),
)
init?.signal?.addEventListener('abort', () => {
controller.error(new DOMException('Aborted', 'AbortError'))
})
},
})
return new Response(body, { status: 200 })
}
if (url.includes(`/sessions/${encodeURIComponent(serverSessionId)}`)) return createAgentResponse(completedSession)
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('继续后台处理')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
await flushPromises()
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
sessionId: serverSessionId,
streamRecovery: {
sessionId: serverSessionId,
},
})
visibilityState = 'visible'
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
expect(wrapper.text()).toContain('前后台恢复成功')
expect(wrapper.text()).toContain('agentAssistant.ready')
wrapper.unmount()
})
it('does not send when Enter confirms an IME composition', async () => {
const fetchMock = vi.fn(async () => createAgentResponse([]))
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await flushPromises()
fetchMock.mockClear()
await textarea.setValue('搜索电影')
await textarea.trigger('compositionstart')
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
await textarea.trigger('compositionend')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(fetchMock).toHaveBeenCalled()
wrapper.unmount()
})
it('renders protected delivery only as transient literal text and advertises the stream capability', async () => {
const protectedMarker = 'MP-PROTECTED-MARKER **not bold** <script>literal</script>'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:protected' }),
legacySseFrame({ type: 'delta', content: '普通回复' }),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-1',
content_type: 'text/plain',
content: protectedMarker,
}),
legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }),
legacySseFrame({ type: 'done' }),
])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取受保护结果')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const protectedNode = wrapper.get('[data-protected-delivery-id="delivery-1"]')
expect(protectedNode.text()).toBe(protectedMarker)
expect(protectedNode.find('script').exists()).toBe(false)
const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble')
expect(assistantBubble.text()).not.toContain(protectedMarker)
expect(assistantBubble.text()).toContain('普通回复')
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamHeaders = new Headers(streamCall?.[1]?.headers)
expect(streamHeaders.get('X-MoviePilot-Agent-Interaction')).toBe('1')
const displayCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes('/display'))
expect(displayCalls.length).toBeGreaterThan(0)
displayCalls.forEach(([, init]) => {
expect(new Headers(init?.headers).has('X-MoviePilot-Agent-Interaction')).toBe(false)
expect(String(init?.body)).not.toContain(protectedMarker)
})
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(protectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(protectedMarker)
expect(JSON.stringify(wrapper.emitted('assistant-preview') || [])).not.toContain(protectedMarker)
wrapper.unmount()
})
it('ignores empty data heartbeats without interrupting ordinary stream events', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createRawAgentStreamResponse(
[
'data:',
'',
'data: ',
'',
'data',
'',
'data: {"type":"delta","content":"心跳后的普通回复"}',
'',
'data: {"type":"done"}',
'',
].join('\r\n'),
)
}
return createAgentResponse([])
}),
)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('测试空数据心跳')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.get('.agent-assistant-message--assistant').text()).toContain('心跳后的普通回复')
expect(wrapper.find('.agent-assistant-message--error').exists()).toBe(false)
wrapper.unmount()
})
it('ignores unknown and mismatched named protocols before the ordinary event queue', async () => {
const rejectedMarker = 'MP-REJECTED-NAMED-EVENT'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createRawAgentStreamResponse(
[
'event: ping',
'data: keepalive',
'',
'event: interaction',
`data: {"type":"interaction","content":"${rejectedMarker}"}`,
'',
'event: delta',
`data: {"type":"tool","message":"${rejectedMarker}"}`,
'',
'event: message',
`data: {"type":"future_event","content":"${rejectedMarker}"}`,
'',
'event: delta',
'data: {"type":"delta","content":"未知协议后的普通回复"}',
'',
'event: done',
'data: {"type":"done"}',
'',
].join('\n'),
)
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('测试未知具名协议')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.get('.agent-assistant-message--assistant').text()).toContain('未知协议后的普通回复')
expect(wrapper.text()).not.toContain(rejectedMarker)
expect(wrapper.find('.agent-assistant-message--error').exists()).toBe(false)
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(rejectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(rejectedMarker)
expect(JSON.stringify(wrapper.emitted('assistant-preview') || [])).not.toContain(rejectedMarker)
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(rejectedMarker))
wrapper.unmount()
})
it('ignores null and non-object JSON frames without interrupting ordinary stream events', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createRawAgentStreamResponse(
[
'data: null',
'',
'data: 42',
'',
'data: "heartbeat"',
'',
'data: []',
'',
'data: {"type":"delta","content":"空值帧后的普通回复"}',
'',
'data: {"type":"done"}',
'',
].join('\n'),
)
}
return createAgentResponse([])
}),
)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('测试 JSON 空值帧')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.get('.agent-assistant-message--assistant').text()).toContain('空值帧后的普通回复')
expect(wrapper.find('.agent-assistant-message--error').exists()).toBe(false)
wrapper.unmount()
})
it('keeps consuming ordinary JSON frames with explicit SSE event names', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
{ eventName: 'message', data: { type: 'start', session_id: 'web-agent:named-events' } },
{ eventName: 'delta', data: { type: 'delta', content: '具名普通回复' } },
{ eventName: 'done', data: { type: 'done' } },
])
}
return createAgentResponse([])
}),
)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('测试普通具名事件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.get('.agent-assistant-message--assistant').text()).toContain('具名普通回复')
const persistedState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
expect(persistedState.streamRecovery).toBeNull()
wrapper.unmount()
})
it('ignores malformed and duplicate protected delivery events without creating a normal message', async () => {
const acceptedMarker = 'MP-ACCEPTED-PROTECTED-MARKER'
const rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER'
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:protected-validation' }),
protectedSseFrame({
schema_version: '2.0',
delivery_id: 'wrong-major',
content_type: 'text/plain',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: '',
content_type: 'text/plain',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'wrong-content-type',
content_type: 'text/html',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'bad-content',
content_type: 'text/plain',
content: 42,
}),
{ eventName: 'interaction', data: { content: rejectedMarker } },
legacySseFrame({ type: 'protected_delivery', delivery_id: 'legacy-shape', content: rejectedMarker }),
protectedSseFrame({
schema_version: '1.7',
delivery_id: 'delivery-valid',
content_type: 'text/plain',
content: acceptedMarker,
future_field: true,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-valid',
content_type: 'text/plain',
content: rejectedMarker,
}),
legacySseFrame({ type: 'done' }),
])
}
return createAgentResponse([])
}),
)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取一次性结果')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const protectedNodes = wrapper.findAll('.agent-assistant-protected-delivery')
expect(protectedNodes).toHaveLength(1)
expect(protectedNodes[0].text()).toBe(acceptedMarker)
expect(wrapper.text()).not.toContain(rejectedMarker)
expect(wrapper.find('.agent-assistant-message--assistant').exists()).toBe(false)
wrapper.unmount()
})
it('clears protected delivery on close and rejects a late frame from the old stream after reopen', async () => {
const protectedMarker = 'MP-LATE-PROTECTED-MARKER'
const stream = createControllableAgentStream()
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') return stream.response
return createAgentResponse([])
}),
)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取晚到结果')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' }))
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-close',
content_type: 'text/plain',
content: protectedMarker,
}),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
await wrapper.setProps({ modelValue: false })
expect(wrapper.text()).not.toContain(protectedMarker)
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-after-close',
content_type: 'text/plain',
content: protectedMarker,
}),
)
await wrapper.setProps({ modelValue: true })
await flushPromises()
expect(wrapper.text()).not.toContain(protectedMarker)
stream.close()
await flushPromises()
expect(JSON.stringify(localStorage)).not.toContain(protectedMarker)
wrapper.unmount()
})
it('clears protected delivery when the stream ends without a terminal event and enters recovery', async () => {
const protectedMarker = 'MP-EOF-PROTECTED-MARKER'
const stream = createControllableAgentStream()
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') return stream.response
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取后模拟断流')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-eof',
content_type: 'text/plain',
content: protectedMarker,
}),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
stream.close()
await flushPromises()
const persistedState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
expect(wrapper.text()).not.toContain(protectedMarker)
expect(persistedState.streamRecovery).toMatchObject({ attempts: 0 })
expect(JSON.stringify(persistedState)).not.toContain(protectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(protectedMarker)
expect(JSON.stringify(wrapper.emitted('assistant-preview') || [])).not.toContain(protectedMarker)
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(protectedMarker))
wrapper.unmount()
})
it('clears protected delivery on a recoverable stream error while preserving ordinary recovery state', async () => {
const protectedMarker = 'MP-NETWORK-PROTECTED-MARKER'
const stream = createControllableAgentStream()
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') return stream.response
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取后模拟网络断开')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-network-error',
content_type: 'text/plain',
content: protectedMarker,
}),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
stream.fail(new TypeError('Failed to fetch'))
await flushPromises()
const persistedState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
expect(wrapper.text()).not.toContain(protectedMarker)
expect(persistedState.streamRecovery).toMatchObject({ attempts: 0 })
expect(persistedState.messages.at(-1)).toMatchObject({ role: 'assistant', status: 'streaming' })
expect(JSON.stringify(persistedState)).not.toContain(protectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(protectedMarker)
expect(JSON.stringify(wrapper.emitted('assistant-preview') || [])).not.toContain(protectedMarker)
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(protectedMarker))
wrapper.unmount()
})
it('clears protected delivery for a new session and never restores it after remount', async () => {
const protectedMarker = 'MP-SESSION-PROTECTED-MARKER'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-session',
content_type: 'text/plain',
content: protectedMarker,
}),
legacySseFrame({ type: 'done' }),
])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取会话结果')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
await wrapper.get('[title="agentAssistant.newChat"]').trigger('click')
expect(wrapper.text()).not.toContain(protectedMarker)
wrapper.unmount()
const remounted = mountPanel()
await flushPromises()
expect(remounted.text()).not.toContain(protectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(protectedMarker)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(protectedMarker)
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(protectedMarker))
remounted.unmount()
})
it('clears protected delivery before loading another history session', async () => {
const protectedMarker = 'MP-HISTORY-PROTECTED-MARKER'
const historySessionId = 'web-agent:history-target'
const historyMessage = {
id: 'history-user',
role: 'user',
content: '历史会话内容',
createdAt: Date.now() - 1000,
status: 'done',
attachments: [],
choices: [],
tools: [],
}
const serverSession = {
session_id: historySessionId,
client_session_id: 'history-target',
title: '历史会话',
updated_at: new Date().toISOString(),
is_processing: false,
messages: [historyMessage],
}
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-history',
content_type: 'text/plain',
content: protectedMarker,
}),
legacySseFrame({ type: 'done' }),
])
}
if (url.includes(`/sessions/${encodeURIComponent(historySessionId)}`)) return createAgentResponse(serverSession)
if (url.includes('/message/agent/sessions?')) return createAgentResponse([serverSession])
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('textarea').setValue('读取后切换历史')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
await wrapper.get('.agent-assistant-history-item').trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain(protectedMarker)
expect(wrapper.text()).toContain('历史会话内容')
wrapper.unmount()
})
it('keeps exact confirmation controls out of Web history while preserving the old-backend response', async () => {
const controlText = '确认 aB12-cD34'
const backendFeedback = '确认无效或已过期'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
legacySseFrame({ type: 'delta', content: backendFeedback }),
legacySseFrame({ type: 'done' }),
])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue(controlText)
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
expect(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: false })
expect(wrapper.text()).toContain(backendFeedback)
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(controlText)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(controlText)
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(controlText))
wrapper.unmount()
})
it.each([
['leading space', ' 确认 AB12-CD34'],
['trailing space', '确认 AB12-CD34 '],
['repeated separator', '确认 AB12-CD34'],
['trailing newline', '确认 AB12-CD34\n'],
['non-breaking space', '确认\u00a0AB12-CD34'],
['full-width space', '确认 AB12-CD34'],
['extra prose', '确认 AB12-CD34 请执行'],
['malformed code', '确认 AB12-CD3'],
['different verb', '同意 AB12-CD34'],
])('keeps the %s control-like text on the ordinary echo path', async (_caseName, controlLikeText) => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([legacySseFrame({ type: 'done' })])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue(controlLikeText)
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
expect(streamBody.echo_user).toBe(true)
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(true)
wrapper.unmount()
})
it('keeps an exact confirmation string with an attachment on the ordinary echo path', async () => {
const controlText = '确认 AB12-CD34'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/upload') && init?.method === 'POST') {
return createAgentResponse({
ref: 'attachment-ref',
url: '/api/v1/message/agent/attachments/attachment-ref',
name: 'proof.txt',
mime_type: 'text/plain',
size: 5,
kind: 'file',
})
}
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([legacySseFrame({ type: 'done' })])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
const fileInput = wrapper.get('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
configurable: true,
value: [new File(['proof'], 'proof.txt', { type: 'text/plain' })],
})
await fileInput.trigger('change')
await wrapper.find('textarea').setValue(controlText)
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
expect(streamBody.echo_user).toBe(true)
expect(wrapper.find('.agent-assistant-message--user').text()).toContain(controlText)
const uploadCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/upload'))
const uploadHeaders = new Headers(uploadCall?.[1]?.headers)
expect(uploadHeaders.has('X-MoviePilot-Agent-Interaction')).toBe(false)
wrapper.unmount()
})
it('clears protected delivery through Escape and the close button', async () => {
const protectedMarker = 'MP-CLOSE-PROTECTED-MARKER'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: `delivery-${fetchMock.mock.calls.length}`,
content_type: 'text/plain',
content: protectedMarker,
}),
legacySseFrame({ type: 'done' }),
])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue('读取后按 Escape')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(wrapper.text()).not.toContain(protectedMarker)
await wrapper.setProps({ modelValue: true })
await wrapper.find('textarea').setValue('读取后点击关闭')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
await wrapper.get('[title="common.close"]').trigger('click')
expect(wrapper.text()).not.toContain(protectedMarker)
wrapper.unmount()
})
it('renders interleaved assistant text and tool events in their SSE order', async () => {
const serverSessionId = 'web-agent:ordered-segments'
const streamEvents = [
{ type: 'start', session_id: serverSessionId },
{ type: 'delta', content: '先检查服务器。' },
{ type: 'tool', message: '(执行了 1 条命令)' },
{ type: 'delta', content: '检查完成,没有发现错误。' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('检查服务器')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toContain('先检查服务器。')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
expect(savedMessages.at(-1)).toMatchObject({
content: '先检查服务器。检查完成,没有发现错误。',
segments: [
{ type: 'text', content: '先检查服务器。' },
{ type: 'tool', toolIndex: 0 },
{ type: 'text', content: '检查完成,没有发现错误。' },
],
})
wrapper.unmount()
})
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
{ type: 'delta', content: '最终结论' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '\n\n' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('检查配置')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(2)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toBe('最终结论')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
wrapper.unmount()
})
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:tool-groups' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'tool', message: '(查看了 1 个目录)' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '继续分析。' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('分析插件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[1].text()).toBe('继续分析。')
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
wrapper.unmount()
})
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:coalesced' },
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('测试突发事件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
)
wrapper.unmount()
})
})