From 2c4b859729fa9978c4891db4a8eb7125d41ec1e2 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:39:47 +0800 Subject: [PATCH] fix(agent): scope secret confirmation controls (#671) --- src/components/agent/AgentAssistantPanel.vue | 64 ++----- .../__tests__/AgentAssistantPanel.spec.ts | 172 +++++++----------- 2 files changed, 81 insertions(+), 155 deletions(-) diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue index b8aac853..7ff8f640 100644 --- a/src/components/agent/AgentAssistantPanel.vue +++ b/src/components/agent/AgentAssistantPanel.vue @@ -181,13 +181,6 @@ interface AgentStreamReadResult { receivedTerminalEvent: boolean } -interface AgentProtectedDelivery { - /** 一次性投递标识,只用于当前可见范围去重。 */ - id: string - /** 仅驻留组件内存并按纯文本渲染的受保护内容。 */ - content: string -} - interface ParsedSseBlock { eventName: string data: string @@ -249,7 +242,8 @@ const STREAM_STATE_PERSIST_DELAY = 1000 const inputText = ref('') const messages = ref([]) -const protectedDeliveries = ref([]) +// 受保护内容只驻留当前组件内存,不进入消息历史或本地持久化。 +const protectedDeliveries = ref([]) const historySessions = ref([]) const sessionId = ref('') const sending = ref(false) @@ -290,7 +284,6 @@ let streamRecoveryAbortRequested = false let streamRecoveryTimer: number | null = null let activeStreamStartedAt = 0 let protectedDeliveryGeneration = 0 -const protectedDeliveryIds = new Set() // 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。 const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value)) @@ -365,12 +358,6 @@ function createSessionId() { 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) } // 将未知字段安全转换为可展示文本。 @@ -1574,25 +1561,10 @@ function splitSseBlock(block: string) { return { eventName, data } satisfies ParsedSseBlock } -function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | null { +function parseProtectedTransportFrame(data: string): string | null { try { const frame = JSON.parse(data) as Record - 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 } + return typeof frame.content === 'string' ? frame.content : null } catch { return null } @@ -1601,11 +1573,10 @@ function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | nu function consumeProtectedTransportFrame(data: string, streamGeneration: number) { if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return - const delivery = parseProtectedTransportFrame(data) - if (!delivery || protectedDeliveryIds.has(delivery.id)) return + const content = parseProtectedTransportFrame(data) + if (content === null) return - protectedDeliveryIds.add(delivery.id) - protectedDeliveries.value.push(delivery) + protectedDeliveries.value.push(content) nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })) } @@ -1859,7 +1830,9 @@ async function streamAgentMessage( const displayContent = (displayText ?? content).trim() if (!content && !images.length && !files.length && !audioRefs.length) return - if (echoUser) addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection) + const userMessage = echoUser + ? addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection) + : null const assistantMessage = addMessage('assistant', '', 'streaming') abortController = new AbortController() @@ -1897,6 +1870,11 @@ async function streamAgentMessage( if (!response.ok) { throw new Error(await resolveAgentResponseErrorMessage(response)) } + if (response.headers.get('X-MoviePilot-Agent-Control') === 'secret-confirmation' && userMessage) { + messages.value = messages.value.filter(message => message.id !== userMessage.id) + refreshMessageList() + persistState() + } const streamResult = await readAgentStream(response, assistantMessage, streamProtectedDeliveryGeneration) shouldFollowBottomAfterStream = isMessageScrollerNearBottom() @@ -1979,10 +1957,7 @@ async function sendMessage() { try { const prepared = await prepareAgentAttachments(attachments) - const reservedControl = attachments.length === 0 && isReservedConfirmationControl(rawText) - await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments, { - echoUser: !reservedControl, - }) + await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments) } catch (error: any) { // 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。 addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error') @@ -2725,13 +2700,12 @@ onScopeDispose(() => {
diff --git a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts index ca87d546..e6ffae2f 100644 --- a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts +++ b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts @@ -107,14 +107,14 @@ function protectedSseFrame(data: Record): SyntheticSseFrame { return { eventName: 'interaction-protected', data } } -function createAgentStreamResponse(frames: SyntheticSseFrame[]) { +function createAgentStreamResponse(frames: SyntheticSseFrame[], headers: Record = {}) { 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' }, + headers: { 'Content-Type': 'text/event-stream', ...headers }, }) } @@ -502,12 +502,7 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ content: protectedMarker }), legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }), legacySseFrame({ type: 'done' }), ]) @@ -522,7 +517,7 @@ describe('AgentAssistantPanel stream recovery', () => { await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) await flushPromises() - const protectedNode = wrapper.get('[data-protected-delivery-id="delivery-1"]') + const protectedNode = wrapper.get('.agent-assistant-protected-delivery') expect(protectedNode.text()).toBe(protectedMarker) expect(protectedNode.find('script').exists()).toBe(false) const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble') @@ -698,7 +693,7 @@ describe('AgentAssistantPanel stream recovery', () => { wrapper.unmount() }) - it('ignores malformed and duplicate protected delivery events without creating a normal message', async () => { + it('accepts only string content from the named protected event', async () => { const acceptedMarker = 'MP-ACCEPTED-PROTECTED-MARKER' const rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER' vi.stubGlobal( @@ -707,45 +702,10 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ 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, - }), + protectedSseFrame({ content: acceptedMarker }), legacySseFrame({ type: 'done' }), ]) } @@ -786,12 +746,7 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ content: protectedMarker }), ) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) @@ -800,12 +755,7 @@ describe('AgentAssistantPanel stream recovery', () => { expect(wrapper.text()).not.toContain(protectedMarker) stream.emit( - protectedSseFrame({ - schema_version: '1.0', - delivery_id: 'delivery-after-close', - content_type: 'text/plain', - content: protectedMarker, - }), + protectedSseFrame({ content: protectedMarker }), ) await wrapper.setProps({ modelValue: true }) await flushPromises() @@ -832,12 +782,7 @@ describe('AgentAssistantPanel stream recovery', () => { await flushPromises() stream.emit( - protectedSseFrame({ - schema_version: '1.0', - delivery_id: 'delivery-before-eof', - content_type: 'text/plain', - content: protectedMarker, - }), + protectedSseFrame({ content: protectedMarker }), ) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) @@ -873,12 +818,7 @@ describe('AgentAssistantPanel stream recovery', () => { await flushPromises() stream.emit( - protectedSseFrame({ - schema_version: '1.0', - delivery_id: 'delivery-before-network-error', - content_type: 'text/plain', - content: protectedMarker, - }), + protectedSseFrame({ content: protectedMarker }), ) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) @@ -905,12 +845,7 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ content: protectedMarker }), legacySseFrame({ type: 'done' }), ]) } @@ -964,12 +899,7 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ content: protectedMarker }), legacySseFrame({ type: 'done' }), ]) } @@ -993,16 +923,19 @@ describe('AgentAssistantPanel stream recovery', () => { wrapper.unmount() }) - it('keeps exact confirmation controls out of Web history while preserving the old-backend response', async () => { - const controlText = '确认 aB12-cD34' + it('keeps exact confirmation controls out of Web history while preserving the backend response', async () => { + const controlText = '确认' 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 createAgentStreamResponse( + [ + legacySseFrame({ type: 'start', session_id: 'web-agent:control' }), + legacySseFrame({ type: 'delta', content: backendFeedback }), + legacySseFrame({ type: 'done' }), + ], + { 'X-MoviePilot-Agent-Control': 'secret-confirmation' }, + ) } return createAgentResponse([]) @@ -1016,28 +949,52 @@ describe('AgentAssistantPanel stream recovery', () => { 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(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: true }) 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) + const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}') + const localHistory = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-history') || '[]') + expect(localState.messages || []).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText })) + expect(localHistory).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText })) fetchMock.mock.calls .filter(([input]) => String(input).includes('/display')) - .forEach(([, init]) => expect(String(init?.body)).not.toContain(controlText)) + .forEach(([, init]) => { + const displayBody = JSON.parse(String(init?.body || '{}')) + expect(displayBody.messages || []).not.toContainEqual( + expect.objectContaining({ role: 'user', content: controlText }), + ) + }) wrapper.unmount() }) + it('keeps an exact confirmation word on the ordinary path without a pending control', async () => { + const controlText = '确认' + 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(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) + 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'], + ['confirmation with details', '确认 TMDB_API_KEY'], + ['cancellation with details', '取消 TMDB_API_KEY'], + ['extra prose', '确认,请执行'], + ['different verb', '同意'], ])('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') { @@ -1060,7 +1017,7 @@ describe('AgentAssistantPanel stream recovery', () => { }) it('keeps an exact confirmation string with an attachment on the ordinary echo path', async () => { - const controlText = '确认 AB12-CD34' + const controlText = '确认' const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url.endsWith('/message/agent/upload') && init?.method === 'POST') { @@ -1142,12 +1099,7 @@ describe('AgentAssistantPanel stream recovery', () => { 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, - }), + protectedSseFrame({ content: protectedMarker }), legacySseFrame({ type: 'done' }), ]) }