Merge pull request 'Send the same request id when an outbox message is retried' (#37) from feature/pi-durable-borrow into main

Send the same request id when an outbox message is retried
This commit was merged in pull request #37.
This commit is contained in:
2026-10-03 14:44:39 +00:00
4 changed files with 25 additions and 9 deletions
+2 -2
View File
@@ -48,8 +48,8 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[]) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds }) })
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
+6
View File
@@ -31,6 +31,12 @@ export function replaceQueuedText(queue: QueuedMessage[], id: string, text: stri
return queue.map(q => q.id === id ? { ...q, text } : q)
}
// deliveryRequestId 是这条排队消息重发时必须复用的 requestId。
// 服务端按它去重,避免忙时 409 再入队后写出第二条用户消息。
export function deliveryRequestId(item: QueuedMessage): string {
return item.id
}
export function promoteQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
const item = queue.find(q => q.id === id)
if (!item) return queue
+7 -6
View File
@@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import type {
@@ -468,10 +468,11 @@ export const store = {
}
},
async sendMessage(text: string, attachmentIds: string[]): Promise<'ok' | 'busy'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
if (!state.currentConvId) return 'ok'
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
@@ -491,7 +492,7 @@ export const store = {
return 'ok'
} catch (e) {
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, newQueuedMessage(text, attachmentIds, [])) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
@@ -522,7 +523,7 @@ export const store = {
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds)
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
},
async flushOutbox() {
@@ -533,7 +534,7 @@ export const store = {
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds)
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
} finally {
set({ outboxDraining: false })
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
removeQueuedMessage, replaceQueuedText,
} from '../src/outbox'
@@ -25,4 +25,13 @@ describe('outbox', () => {
const again = enqueueMessageFront(q, b)
expect(again.map(x => x.id)).toEqual([b.id, a.id])
})
it('reuses the queued id as the request id when the send is retried', () => {
const item = newQueuedMessage('hi', ['f1'], ['note.md'])
const queued = enqueueMessageFront([], item)
expect(deliveryRequestId(queued[0])).toBe(item.id)
const retried = enqueueMessageFront(queued, { ...item, text: item.text })
expect(deliveryRequestId(retried[0])).toBe(item.id)
expect(retried).toHaveLength(1)
})
})