fix(agent): defer secret control history (#672)

This commit is contained in:
InfinityPacer
2026-08-13 08:20:39 +08:00
committed by GitHub
parent e4b1ba34e1
commit 96d3991579
2 changed files with 116 additions and 24 deletions

View File

@@ -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)

View File

@@ -152,6 +152,15 @@ function createControllableAgentStream() {
}
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise
})
return { promise, resolve }
}
const slotContainerStub = { template: '<div><slot /></div>' }
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<Response>()
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<Response>()
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) => {