Browse Source

修复本地失败消息随同步移动到聊天末尾

Samien 1 month ago
parent
commit
571754ad25

+ 4 - 2
src/client/stores/chat.ts

@@ -147,7 +147,9 @@ export const useChatStore = defineStore('chat', () => {
         })
         })
         const ids=new Set(canonical.flatMap(m=>[m.id,m.clientMessageId].filter(Boolean)))
         const ids=new Set(canonical.flatMap(m=>[m.id,m.clientMessageId].filter(Boolean)))
         const pending=initial.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         const pending=initial.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
-        const messages=[...canonical,...pending]
+        // Unsent local inputs keep their original time among server messages;
+        // appending the outbox would move old failures below every new reply.
+        const messages=mergeChatMessages(canonical,pending)
         const {messages:_,...checkpoint}=value
         const {messages:_,...checkpoint}=value
         await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint},true)
         await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint},true)
         const beforeOutbox=current();if(!beforeOutbox)return
         const beforeOutbox=current();if(!beforeOutbox)return
@@ -157,7 +159,7 @@ export const useChatStore = defineStore('chat', () => {
         // the current route after both writes, preserving any newer local send.
         // the current route after both writes, preserving any newer local send.
         const latest=current();if(!latest)return
         const latest=current();if(!latest)return
         const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
-        latest.messages=[...canonical,...latestPending];latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
+        latest.messages=mergeChatMessages(canonical,latestPending);latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
         const approval=value.pendingApproval,clarification=value.pendingClarification
         const approval=value.pendingApproval,clarification=value.pendingClarification
         latest.pendingApproval=approval?{id:string(approval.request_id??approval.id),sessionId:id,message:string(approval.message??approval.prompt),toolName:string(approval.tool_name),choices:values(approval.choices).map(String),payload:approval as Record<string,JsonValue>}:undefined
         latest.pendingApproval=approval?{id:string(approval.request_id??approval.id),sessionId:id,message:string(approval.message??approval.prompt),toolName:string(approval.tool_name),choices:values(approval.choices).map(String),payload:approval as Record<string,JsonValue>}:undefined
         latest.pendingClarification=clarification?{id:string(clarification.request_id??clarification.id),sessionId:id,question:string(clarification.question??clarification.prompt),payload:clarification as Record<string,JsonValue>}:undefined
         latest.pendingClarification=clarification?{id:string(clarification.request_id??clarification.id),sessionId:id,question:string(clarification.question??clarification.prompt),payload:clarification as Record<string,JsonValue>}:undefined

+ 29 - 0
tests/client/chatCrossClientSync.test.ts

@@ -137,6 +137,35 @@ describe('ordinary v2 cross-client store', () => {
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     return wire.clients.filter((c) => c.id === id).at(-1)!
     return wire.clients.filter((c) => c.id === id).at(-1)!
   }
   }
+  it.each(['failed', 'unknown-receipt'] as const)('keeps an older %s input before newer messages through sync and cache restoration', async stage => {
+    const client = await open()
+    const oldAnswer = answer('之前的回复', { timestamp: 10 })
+    await client.changed(snapshot([oldAnswer]))
+    chat.activeRouteState!.messages.push({
+      id: 'local-failed', clientMessageId: 'local-failed', sessionId: 's', profile: 'p',
+      role: 'user', content: '再试试', timestamp: 15, stage,
+    })
+    const newerUser = { id: 'new-user', seq: 3, revision: 1, role: 'user', content: '再试试', timestamp: 20, status: 'complete' }
+    const newerAnswer = answer('新的回复', { id: 'new-answer', seq: 4, timestamp: 21 })
+    const expected = ['canonical-answer', 'local-failed', 'new-user', 'new-answer']
+    await client.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 2 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    await client.changed(snapshot([oldAnswer, newerUser, { ...newerAnswer, revision: 2 }], 's', { cursor: 3 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    const cached = await new ScopedCache<{ messages: { id: string }[] }>('chat-history-v1').get(`v2-viewer-${wire.owner}:p`, 's')
+    expect(cached?.messages.map(m => m.id)).toEqual(expected)
+
+    chat.disconnect()
+    setActivePinia(createPinia())
+    chat = useChatStore()
+    chat.sessions = [session()]
+    wire.clients = []
+    const restored = await open()
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    await restored.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 4 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    expect(chat.messages.find(m => m.id === 'local-failed')?.stage).toBe(stage)
+  })
   it('keeps a model-switch confirmation through transcript updates and sends after confirmation', async () => {
   it('keeps a model-switch confirmation through transcript updates and sends after confirmation', async () => {
     const client = await open()
     const client = await open()
     const model = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
     const model = { id: 'model-b', name: 'Model B', provider: 'provider-b' }

+ 17 - 1
tests/e2e/model-confirmation.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from '@playwright/test'
 import { test, expect } from '@playwright/test'
 
 
-test('model confirmation survives transcript reconnects and the Allow button completes the switch', async ({ page }) => {
+test('model confirmation survives reconnects and failed inputs retain their position after Allow, new replies and reload', async ({ page, browser }) => {
   const modelCommands: Record<string, unknown>[] = []
   const modelCommands: Record<string, unknown>[] = []
   let nativeApprovals = 0
   let nativeApprovals = 0
   await page.route('**/api/realtime/channels/*/commands', async route => {
   await page.route('**/api/realtime/channels/*/commands', async route => {
@@ -43,4 +43,20 @@ test('model confirmation survives transcript reconnects and the Allow button com
   await page.getByRole('button', { name: '发送消息', exact: true }).click()
   await page.getByRole('button', { name: '发送消息', exact: true }).click()
   await expect(page.locator('.message__content').filter({ hasText: '这是来自假 Gateway 的流式回复。' })).toHaveCount(1)
   await expect(page.locator('.message__content').filter({ hasText: '这是来自假 Gateway 的流式回复。' })).toHaveCount(1)
   expect(modelCommands).toHaveLength(2)
   expect(modelCommands).toHaveLength(2)
+  const relevantMessages = ['等待模型确认', '模型确认后发送', '这是来自假 Gateway 的流式回复。']
+  const visibleOrder = async () => (await page.locator('.message__content').allTextContents())
+    .map(text => text.trim()).filter(text => relevantMessages.includes(text))
+  await expect.poll(visibleOrder).toEqual(relevantMessages)
+  await page.reload()
+  await expect.poll(visibleOrder).toEqual(relevantMessages)
+  await expect(page.locator('.message--failed').filter({ hasText: '等待模型确认' })).toHaveCount(1)
+
+  // A fresh device gets the accepted transcript, not this browser's unsent outbox.
+  const other = await browser.newContext({ storageState: await page.context().storageState() })
+  try {
+    const otherPage = await other.newPage()
+    await otherPage.goto(page.url())
+    await expect(otherPage.locator('.message__content').filter({ hasText: '模型确认后发送' })).toHaveCount(1)
+    await expect(otherPage.locator('.message__content').filter({ hasText: '等待模型确认' })).toHaveCount(0)
+  } finally { await other.close() }
 })
 })