From 96d399157966ffaa4d45bb897883ce1e1cc1bca3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:20:39 +0800 Subject: [PATCH] fix(agent): defer secret control history (#672) --- src/components/agent/AgentAssistantPanel.vue | 22 ++-- .../__tests__/AgentAssistantPanel.spec.ts | 118 ++++++++++++++++-- 2 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue index 7ff8f640..ef4f5383 100644 --- a/src/components/agent/AgentAssistantPanel.vue +++ b/src/components/agent/AgentAssistantPanel.vue @@ -1830,11 +1830,6 @@ async function streamAgentMessage( const displayContent = (displayText ?? content).trim() if (!content && !images.length && !files.length && !audioRefs.length) return - const userMessage = echoUser - ? addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection) - : null - const assistantMessage = addMessage('assistant', '', 'streaming') - abortController = new AbortController() userAbortRequested = false streamRecoveryAbortRequested = false @@ -1843,6 +1838,7 @@ async function streamAgentMessage( activeStreamStartedAt = streamStartedAt let shouldFollowBottomAfterStream = true let shouldSaveClientSnapshot = true + let assistantMessage: AgentChatMessage | null = null try { const response = await fetch(resolveApiUrl('message/agent/stream'), { @@ -1867,14 +1863,14 @@ async function streamAgentMessage( signal: abortController.signal, }) + const isSecretConfirmation = response.headers.get('X-MoviePilot-Agent-Control') === 'secret-confirmation' + if (!isSecretConfirmation && echoUser) { + addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection) + } + assistantMessage = addMessage('assistant', '', 'streaming') 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() @@ -1890,7 +1886,8 @@ async function streamAgentMessage( pendingStreamRecovery.value = null clearStreamRecoveryTimer() if (isEmptyAssistantMessage(assistantMessage)) { - messages.value = messages.value.filter(message => message.id !== assistantMessage.id) + const emptyAssistantMessageId = assistantMessage.id + messages.value = messages.value.filter(message => message.id !== emptyAssistantMessageId) refreshMessageList() return } @@ -1903,6 +1900,7 @@ async function streamAgentMessage( if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return if (error?.name === 'AbortError' && userAbortRequested) { + if (!assistantMessage) return assistantMessage.status = 'done' markToolsDone(assistantMessage) refreshMessageList() @@ -1910,6 +1908,7 @@ async function streamAgentMessage( } if (isRecoverableStreamDisconnect(error)) { + if (!assistantMessage) return shouldSaveClientSnapshot = false invalidateProtectedDeliveries() beginStreamRecovery(sessionId.value, streamStartedAt) @@ -1919,6 +1918,7 @@ async function streamAgentMessage( return } + assistantMessage ||= addMessage('assistant', '', 'streaming') assistantMessage.status = 'error' replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error')) markToolsDone(assistantMessage) diff --git a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts index e6ffae2f..50ed58ae 100644 --- a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts +++ b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts @@ -152,6 +152,15 @@ function createControllableAgentStream() { } } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + const slotContainerStub = { template: '
' } const menuStub = defineComponent({ setup(_props, { slots }) { @@ -745,18 +754,14 @@ describe('AgentAssistantPanel stream recovery', () => { await flushPromises() stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' })) - stream.emit( - protectedSseFrame({ content: protectedMarker }), - ) + stream.emit(protectedSseFrame({ content: protectedMarker })) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) await wrapper.setProps({ modelValue: false }) expect(wrapper.text()).not.toContain(protectedMarker) - stream.emit( - protectedSseFrame({ content: protectedMarker }), - ) + stream.emit(protectedSseFrame({ content: protectedMarker })) await wrapper.setProps({ modelValue: true }) await flushPromises() expect(wrapper.text()).not.toContain(protectedMarker) @@ -781,9 +786,7 @@ describe('AgentAssistantPanel stream recovery', () => { await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) await flushPromises() - stream.emit( - protectedSseFrame({ content: protectedMarker }), - ) + stream.emit(protectedSseFrame({ content: protectedMarker })) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) @@ -817,9 +820,7 @@ describe('AgentAssistantPanel stream recovery', () => { await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) await flushPromises() - stream.emit( - protectedSseFrame({ content: protectedMarker }), - ) + stream.emit(protectedSseFrame({ content: protectedMarker })) await flushPromises() expect(wrapper.text()).toContain(protectedMarker) @@ -954,7 +955,9 @@ describe('AgentAssistantPanel stream recovery', () => { expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false) 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(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')) @@ -968,6 +971,95 @@ describe('AgentAssistantPanel stream recovery', () => { wrapper.unmount() }) + it('does not commit confirmation controls before the response header classifies the request', async () => { + const controlText = '确认' + const backendFeedback = '确认请求已处理' + const deferredStreamResponse = createDeferred() + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') { + return deferredStreamResponse.promise + } + + return Promise.resolve(createAgentResponse([])) + }) + vi.stubGlobal('fetch', fetchMock) + + const wrapper = mountPanel() + await wrapper.find('textarea').setValue(controlText) + await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) + await flushPromises() + + 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) + expect(fetchMock.mock.calls.some(([input]) => String(input).includes('/display'))).toBe(false) + + deferredStreamResponse.resolve( + createAgentStreamResponse( + [legacySseFrame({ type: 'delta', content: backendFeedback }), legacySseFrame({ type: 'done' })], + { 'X-MoviePilot-Agent-Control': 'secret-confirmation' }, + ), + ) + await flushPromises() + + expect(wrapper.text()).toContain(backendFeedback) + expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false) + 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]) => { + const displayBody = JSON.parse(String(init?.body || '{}')) + expect(displayBody.messages || []).not.toContainEqual( + expect.objectContaining({ role: 'user', content: controlText }), + ) + }) + + wrapper.unmount() + }) + + it('commits ordinary messages in user and assistant order after response classification', async () => { + const userText = '检查下载任务' + const assistantText = '检查完成' + const deferredStreamResponse = createDeferred() + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') { + return deferredStreamResponse.promise + } + + return Promise.resolve(createAgentResponse([])) + }) + vi.stubGlobal('fetch', fetchMock) + + const wrapper = mountPanel() + await wrapper.find('textarea').setValue(userText) + await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.findAll('.agent-assistant-message')).toHaveLength(0) + + deferredStreamResponse.resolve( + createAgentStreamResponse([ + legacySseFrame({ type: 'delta', content: assistantText }), + legacySseFrame({ type: 'done' }), + ]), + ) + await flushPromises() + + const messages = wrapper.findAll('.agent-assistant-message') + expect(messages).toHaveLength(2) + expect(messages[0].classes()).toContain('agent-assistant-message--user') + expect(messages[0].text()).toContain(userText) + expect(messages[1].classes()).toContain('agent-assistant-message--assistant') + expect(messages[1].text()).toContain(assistantText) + + 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) => {