mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 02:29:31 +08:00
feat(agent): render protected stream deliveries (#669)
This commit is contained in:
@@ -111,6 +111,17 @@ interface AgentStreamEvent {
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
const AGENT_STREAM_EVENT_TYPES = new Set<AgentStreamEvent['type']>([
|
||||
'start',
|
||||
'delta',
|
||||
'tool',
|
||||
'attachment',
|
||||
'choice',
|
||||
'message_update',
|
||||
'done',
|
||||
'error',
|
||||
])
|
||||
|
||||
interface AgentPendingAttachment {
|
||||
id: string
|
||||
file: File
|
||||
@@ -155,6 +166,18 @@ interface AgentStreamReadResult {
|
||||
receivedTerminalEvent: boolean
|
||||
}
|
||||
|
||||
interface AgentProtectedDelivery {
|
||||
/** 一次性投递标识,只用于当前可见范围去重。 */
|
||||
id: string
|
||||
/** 仅驻留组件内存并按纯文本渲染的受保护内容。 */
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ParsedSseBlock {
|
||||
eventName: string
|
||||
data: string
|
||||
}
|
||||
|
||||
interface AgentSlashCommand {
|
||||
command: string
|
||||
description: string
|
||||
@@ -211,6 +234,7 @@ const STREAM_STATE_PERSIST_DELAY = 1000
|
||||
|
||||
const inputText = ref('')
|
||||
const messages = ref<AgentChatMessage[]>([])
|
||||
const protectedDeliveries = ref<AgentProtectedDelivery[]>([])
|
||||
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||
const sessionId = ref('')
|
||||
const sending = ref(false)
|
||||
@@ -250,6 +274,8 @@ let userAbortRequested = false
|
||||
let streamRecoveryAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let activeStreamStartedAt = 0
|
||||
let protectedDeliveryGeneration = 0
|
||||
const protectedDeliveryIds = new Set<string>()
|
||||
|
||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||
@@ -298,7 +324,7 @@ const recordingTimeText = computed(() => {
|
||||
const drawerWidth = computed(() => (display.mdAndDown.value || fullscreen.value ? '100vw' : '30rem'))
|
||||
// 仅桌面宽屏展示全屏开关,窄屏已默认占满视口。
|
||||
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
|
||||
const hasMessages = computed(() => messages.value.length > 0)
|
||||
const hasConversationContent = computed(() => messages.value.length > 0 || protectedDeliveries.value.length > 0)
|
||||
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
||||
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
||||
const isOpen = computed({
|
||||
@@ -320,6 +346,18 @@ function createSessionId() {
|
||||
return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
// 受保护内容只属于当前可见范围;递增代次可阻止旧流在清理后重新写入。
|
||||
function invalidateProtectedDeliveries() {
|
||||
protectedDeliveryGeneration += 1
|
||||
protectedDeliveries.value = []
|
||||
protectedDeliveryIds.clear()
|
||||
}
|
||||
|
||||
// 识别前端需要隔离展示和持久化的确认控制文本,授权判断仍由后端完成。
|
||||
function isReservedConfirmationControl(value: string) {
|
||||
return /^(确认|取消) ([A-Za-z0-9]{4}-[A-Za-z0-9]{4})$/.test(value)
|
||||
}
|
||||
|
||||
// 将未知字段安全转换为可展示文本。
|
||||
function stringifyChoiceField(value: unknown) {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
@@ -1506,20 +1544,71 @@ function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
schedulePendingStreamDeltaFlush()
|
||||
}
|
||||
|
||||
// 解析一个 SSE 数据块。
|
||||
function parseSseBlock(block: string) {
|
||||
const data = block
|
||||
.split('\n')
|
||||
.filter(line => line.startsWith('data:'))
|
||||
.map(line => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
// 拆分 SSE event name 与 data,确保受保护 frame 在普通事件解析前完成分流。
|
||||
function splitSseBlock(block: string) {
|
||||
let eventName = ''
|
||||
const dataLines: string[] = []
|
||||
|
||||
if (!data) return null
|
||||
return JSON.parse(data) as AgentStreamEvent
|
||||
for (const rawLine of block.split(/\r?\n/)) {
|
||||
if (!rawLine || rawLine.startsWith(':')) continue
|
||||
|
||||
const separatorIndex = rawLine.indexOf(':')
|
||||
const field = separatorIndex >= 0 ? rawLine.slice(0, separatorIndex) : rawLine
|
||||
let value = separatorIndex >= 0 ? rawLine.slice(separatorIndex + 1) : ''
|
||||
if (value.startsWith(' ')) value = value.slice(1)
|
||||
|
||||
if (field === 'event') eventName = value
|
||||
if (field === 'data') dataLines.push(value)
|
||||
}
|
||||
|
||||
if (!dataLines.length) return null
|
||||
|
||||
const data = dataLines.join('\n')
|
||||
if (!data.trim()) return null
|
||||
return { eventName, data } satisfies ParsedSseBlock
|
||||
}
|
||||
|
||||
function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | null {
|
||||
try {
|
||||
const frame = JSON.parse(data) as Record<string, unknown>
|
||||
const schemaVersion = typeof frame.schema_version === 'string' ? frame.schema_version : ''
|
||||
const versionMatch = /^(\d+)\.(\d+)$/.exec(schemaVersion)
|
||||
const deliveryId = frame.delivery_id
|
||||
|
||||
if (
|
||||
!versionMatch ||
|
||||
versionMatch[1] !== '1' ||
|
||||
typeof deliveryId !== 'string' ||
|
||||
!deliveryId.trim() ||
|
||||
frame.content_type !== 'text/plain' ||
|
||||
typeof frame.content !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { id: deliveryId, content: frame.content }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function consumeProtectedTransportFrame(data: string, streamGeneration: number) {
|
||||
if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return
|
||||
|
||||
const delivery = parseProtectedTransportFrame(data)
|
||||
if (!delivery || protectedDeliveryIds.has(delivery.id)) return
|
||||
|
||||
protectedDeliveryIds.add(delivery.id)
|
||||
protectedDeliveries.value.push(delivery)
|
||||
nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow }))
|
||||
}
|
||||
|
||||
// 读取并应用智能助手 SSE 响应流。
|
||||
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage): Promise<AgentStreamReadResult> {
|
||||
async function readAgentStream(
|
||||
response: Response,
|
||||
assistantMessage: AgentChatMessage,
|
||||
streamGeneration: number,
|
||||
): Promise<AgentStreamReadResult> {
|
||||
if (!response.body) {
|
||||
throw new Error(t('agentAssistant.noStream'))
|
||||
}
|
||||
@@ -1530,8 +1619,36 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
let receivedTerminalEvent = false
|
||||
|
||||
// 应用事件并记录服务端是否明确结束本轮流,区分正常完成与无异常的后台断流。
|
||||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||||
if (!event) return
|
||||
const consumeBlock = (block: string) => {
|
||||
const parsedBlock = splitSseBlock(block)
|
||||
if (!parsedBlock) return
|
||||
|
||||
if (parsedBlock.eventName === 'interaction-protected') {
|
||||
consumeProtectedTransportFrame(parsedBlock.data, streamGeneration)
|
||||
return
|
||||
}
|
||||
if (
|
||||
parsedBlock.eventName &&
|
||||
parsedBlock.eventName !== 'message' &&
|
||||
!AGENT_STREAM_EVENT_TYPES.has(parsedBlock.eventName as AgentStreamEvent['type'])
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedEvent = JSON.parse(parsedBlock.data) as unknown
|
||||
if (!parsedEvent || typeof parsedEvent !== 'object' || Array.isArray(parsedEvent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const eventRecord = parsedEvent as Record<string, unknown>
|
||||
if (
|
||||
typeof eventRecord.type !== 'string' ||
|
||||
!AGENT_STREAM_EVENT_TYPES.has(eventRecord.type as AgentStreamEvent['type'])
|
||||
)
|
||||
return
|
||||
|
||||
const event = eventRecord as unknown as AgentStreamEvent
|
||||
if (parsedBlock.eventName && parsedBlock.eventName !== 'message' && parsedBlock.eventName !== event.type) return
|
||||
|
||||
queueStreamEvent(event, assistantMessage)
|
||||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||||
@@ -1547,13 +1664,13 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
buffer = blocks.pop() || ''
|
||||
|
||||
for (const block of blocks) {
|
||||
consumeEvent(parseSseBlock(block))
|
||||
consumeBlock(block)
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
consumeEvent(parseSseBlock(buffer))
|
||||
consumeBlock(buffer)
|
||||
}
|
||||
} finally {
|
||||
flushPendingStreamDelta()
|
||||
@@ -1753,6 +1870,7 @@ async function streamAgentMessage(
|
||||
userAbortRequested = false
|
||||
streamRecoveryAbortRequested = false
|
||||
const streamStartedAt = Date.now()
|
||||
const streamProtectedDeliveryGeneration = protectedDeliveryGeneration
|
||||
activeStreamStartedAt = streamStartedAt
|
||||
let shouldFollowBottomAfterStream = true
|
||||
let shouldSaveClientSnapshot = true
|
||||
@@ -1762,6 +1880,7 @@ async function streamAgentMessage(
|
||||
method: 'POST',
|
||||
headers: buildAgentRequestHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-MoviePilot-Agent-Interaction': '1',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
text: content,
|
||||
@@ -1783,10 +1902,11 @@ async function streamAgentMessage(
|
||||
throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
}
|
||||
|
||||
const streamResult = await readAgentStream(response, assistantMessage)
|
||||
const streamResult = await readAgentStream(response, assistantMessage, streamProtectedDeliveryGeneration)
|
||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||||
if (!streamResult.receivedTerminalEvent) {
|
||||
shouldSaveClientSnapshot = false
|
||||
invalidateProtectedDeliveries()
|
||||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||
refreshMessageList()
|
||||
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||
@@ -1817,6 +1937,7 @@ async function streamAgentMessage(
|
||||
|
||||
if (isRecoverableStreamDisconnect(error)) {
|
||||
shouldSaveClientSnapshot = false
|
||||
invalidateProtectedDeliveries()
|
||||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||
assistantMessage.status = 'streaming'
|
||||
refreshMessageList()
|
||||
@@ -1849,7 +1970,8 @@ async function streamAgentMessage(
|
||||
|
||||
// 发送输入框中的文本和附件。
|
||||
async function sendMessage() {
|
||||
const text = inputText.value.trim()
|
||||
const rawText = inputText.value
|
||||
const text = rawText.trim()
|
||||
const attachments = [...pendingAttachments.value]
|
||||
if ((!text && !attachments.length) || isBusy.value) return
|
||||
|
||||
@@ -1861,7 +1983,10 @@ async function sendMessage() {
|
||||
|
||||
try {
|
||||
const prepared = await prepareAgentAttachments(attachments)
|
||||
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
|
||||
const reservedControl = attachments.length === 0 && isReservedConfirmationControl(rawText)
|
||||
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments, {
|
||||
echoUser: !reservedControl,
|
||||
})
|
||||
} catch (error: any) {
|
||||
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
|
||||
addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
|
||||
@@ -2109,6 +2234,7 @@ function stopGeneration() {
|
||||
|
||||
// 开始新的空白会话。
|
||||
function startNewSession() {
|
||||
invalidateProtectedDeliveries()
|
||||
stopGeneration()
|
||||
sessionId.value = createSessionId()
|
||||
messages.value = []
|
||||
@@ -2127,6 +2253,7 @@ async function loadHistorySession(targetSessionId: string) {
|
||||
if (!historySession) return
|
||||
|
||||
try {
|
||||
invalidateProtectedDeliveries()
|
||||
stopGeneration()
|
||||
if (!historySession.messages.length) {
|
||||
historySession = await loadServerHistorySession(targetSessionId)
|
||||
@@ -2180,6 +2307,7 @@ function formatHistoryTime(timestamp: number) {
|
||||
|
||||
// 关闭智能助手面板。
|
||||
function closeDrawer() {
|
||||
invalidateProtectedDeliveries()
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
@@ -2262,7 +2390,12 @@ watch(drawerWidth, () => {
|
||||
})
|
||||
|
||||
watch(isOpen, open => {
|
||||
if (open) scrollToBottom()
|
||||
if (open) {
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateProtectedDeliveries()
|
||||
})
|
||||
|
||||
watch(isBusy, value => emit('thinking-change', value), { immediate: true })
|
||||
@@ -2285,6 +2418,7 @@ onScopeDispose(clearMessageScrollFrame)
|
||||
onScopeDispose(clearStreamPersistTimer)
|
||||
onScopeDispose(clearPendingStreamDelta)
|
||||
onScopeDispose(clearStreamRecoveryTimer)
|
||||
onScopeDispose(invalidateProtectedDeliveries)
|
||||
onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
@@ -2437,11 +2571,11 @@ onScopeDispose(() => {
|
||||
<main
|
||||
ref="messageListRef"
|
||||
class="agent-assistant-messages"
|
||||
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
||||
:class="{ 'agent-assistant-messages--has-content': hasConversationContent }"
|
||||
@scroll.passive="handleMessageScrollerScroll"
|
||||
>
|
||||
<div class="agent-assistant-messages__content">
|
||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||
<div v-if="!hasConversationContent" class="agent-assistant-empty">
|
||||
<div class="agent-assistant-empty__mark">
|
||||
<VIcon icon="lucide:sparkles" size="28" />
|
||||
</div>
|
||||
@@ -2600,6 +2734,18 @@ onScopeDispose(() => {
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="protectedDeliveries.length" class="agent-assistant-protected-deliveries">
|
||||
<div
|
||||
v-for="delivery in protectedDeliveries"
|
||||
:key="delivery.id"
|
||||
class="agent-assistant-protected-delivery"
|
||||
:data-protected-delivery-id="delivery.id"
|
||||
>
|
||||
<VIcon icon="mdi-shield-lock-outline" size="16" aria-hidden="true" />
|
||||
<span v-text="delivery.content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3155,6 +3301,32 @@ onScopeDispose(() => {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.agent-assistant-protected-deliveries {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
inline-size: min(100%, 34rem);
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.agent-assistant-protected-delivery {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.42);
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-warning), 0.08);
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
column-gap: 0.5rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
line-height: 1.55;
|
||||
min-inline-size: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding-block: 0.65rem;
|
||||
padding-inline: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.agent-assistant-message__meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -29,6 +30,11 @@ interface MockServerSession {
|
||||
messages: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface SyntheticSseFrame {
|
||||
eventName?: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
const agentMarkdownContentStub = {
|
||||
props: ['content', 'variant'],
|
||||
template:
|
||||
@@ -43,9 +49,98 @@ function createAgentResponse(data: unknown) {
|
||||
} 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(() => {
|
||||
@@ -349,6 +444,653 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
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 = [
|
||||
|
||||
Reference in New Issue
Block a user