From 763c4b4804d90041607254bb3fef44306fb7f671 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 14:37:50 +0000 Subject: [PATCH] Send the same request id when an outbox message is retried Why: A busy 409 used to enqueue a new id, so the retry created a second user message after the server had already stored the first. Evidence: vitest run passed, 13 files and 69 tests, including the outbox retry case. Impact: Data layer only. sendMessage and outbox flush reuse the queued message id as requestId. No screen changes. Refs: Dev feature/pi-durable-borrow Co-authored-by: Cursor --- src/api.ts | 4 ++-- src/outbox.ts | 6 ++++++ src/store.ts | 13 +++++++------ test/outbox.test.ts | 11 ++++++++++- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/api.ts b/src/api.ts index 5878845..29faedd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 }> diff --git a/src/outbox.ts b/src/outbox.ts index f51a42a..7599ccd 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -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 diff --git a/src/store.ts b/src/store.ts index ccba8e3..e146ff3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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 }) } diff --git a/test/outbox.test.ts b/test/outbox.test.ts index f61fcd1..07e3c55 100644 --- a/test/outbox.test.ts +++ b/test/outbox.test.ts @@ -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) + }) })