fix: handle ambiguous prompt transport failures
This commit is contained in:
@@ -10,6 +10,7 @@ let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?
|
||||
let questionReplyError: unknown | null = null
|
||||
let questionRejectError: unknown | null = null
|
||||
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
||||
const globalUpsertedSessions: unknown[] = []
|
||||
|
||||
const mockScopedClient = {
|
||||
@@ -41,7 +42,7 @@ const mockSdk = {
|
||||
session: {
|
||||
messages: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.messages", params })
|
||||
return Promise.resolve({ data: [] })
|
||||
return Promise.resolve(sessionMessagesResult)
|
||||
}),
|
||||
revert: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.revert", params })
|
||||
@@ -343,6 +344,7 @@ describe("optimisticSend target directory", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
scopedClientDirectories.length = 0
|
||||
sessionMessagesResult = { data: [] }
|
||||
})
|
||||
|
||||
test("passes the prompt directory to optimistic state during session switch races", async () => {
|
||||
@@ -438,6 +440,95 @@ describe("optimisticSend target directory", () => {
|
||||
expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race")
|
||||
expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle")
|
||||
})
|
||||
|
||||
test("confirms an ambiguous send failure with a recent message refetch", async () => {
|
||||
const targetStore = createStore({})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
let optimisticRemove: OptimisticRemoveCall | null = null
|
||||
let optimisticConfirm: OptimisticRemoveCall | null = null
|
||||
let sentMessageID = ""
|
||||
|
||||
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
||||
setOptimisticRefs(
|
||||
() => {},
|
||||
(input) => {
|
||||
optimisticRemove = input
|
||||
},
|
||||
(input) => {
|
||||
optimisticConfirm = input
|
||||
},
|
||||
)
|
||||
|
||||
await optimisticSend({
|
||||
sessionId: "session-confirmed",
|
||||
directory: "/target/project",
|
||||
content: "hello",
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
send: async (messageID) => {
|
||||
sentMessageID = messageID
|
||||
sessionMessagesResult = {
|
||||
data: [{
|
||||
info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message,
|
||||
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
|
||||
}],
|
||||
}
|
||||
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
||||
error.status = 504
|
||||
throw error
|
||||
},
|
||||
})
|
||||
|
||||
expect(optimisticRemove).toBe(null)
|
||||
expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID)
|
||||
expect(replyCalls.find((call) => call.method === "session.messages")?.params.limit).toBe(30)
|
||||
expect(targetStore.getState().message["session-confirmed"]?.[0]?.id).toBe(sentMessageID)
|
||||
expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part")
|
||||
})
|
||||
|
||||
test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => {
|
||||
const targetStore = createStore({})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
let optimisticRemove: OptimisticRemoveCall | null = null
|
||||
let optimisticConfirm: OptimisticRemoveCall | null = null
|
||||
|
||||
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
||||
setOptimisticRefs(
|
||||
() => {},
|
||||
(input) => {
|
||||
optimisticRemove = input
|
||||
},
|
||||
(input) => {
|
||||
optimisticConfirm = input
|
||||
},
|
||||
)
|
||||
|
||||
let caught: unknown = null
|
||||
try {
|
||||
await optimisticSend({
|
||||
sessionId: "session-missing",
|
||||
directory: "/target/project",
|
||||
content: "hello",
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
send: async () => {
|
||||
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
||||
error.status = 504
|
||||
throw error
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
expect((optimisticRemove as OptimisticRemoveCall | null)?.sessionID).toBe("session-missing")
|
||||
expect(optimisticConfirm).toBe(null)
|
||||
expect(replyCalls.filter((call) => call.method === "session.messages").every((call) => call.params.limit === 30)).toBe(true)
|
||||
expect(targetStore.getState().session_status["session-missing"]?.type).toBe("idle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("respondToPermission passes directory", () => {
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
} from "@/lib/sessionReviewMetadata"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2
|
||||
const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150
|
||||
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const UNREVERT_REFETCH_ATTEMPTS = 3
|
||||
const UNREVERT_REFETCH_RETRY_MS = 150
|
||||
@@ -39,9 +42,11 @@ let _childStores: ChildStoreManager | null = null
|
||||
let _getDirectory: () => string = () => ""
|
||||
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
||||
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
||||
type OptimisticConfirmInput = OptimisticRemoveInput
|
||||
|
||||
let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
|
||||
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
|
||||
let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -100,9 +105,11 @@ export function setActionRefs(
|
||||
export function setOptimisticRefs(
|
||||
add: (input: OptimisticAddInput) => void,
|
||||
remove: (input: OptimisticRemoveInput) => void,
|
||||
confirm?: (input: OptimisticConfirmInput) => void,
|
||||
) {
|
||||
_optimisticAdd = add
|
||||
_optimisticRemove = remove
|
||||
_optimisticConfirm = confirm ?? null
|
||||
}
|
||||
|
||||
function sdk() {
|
||||
@@ -189,6 +196,36 @@ function connectionLostError(): Error {
|
||||
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== "object") return null
|
||||
const direct = (error as { status?: unknown }).status
|
||||
if (typeof direct === "number") return direct
|
||||
const response = (error as { response?: { status?: unknown } }).response
|
||||
return typeof response?.status === "number" ? response.status : null
|
||||
}
|
||||
|
||||
function isAmbiguousSendFailure(error: unknown): boolean {
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503 || status === 504 || status === 408) return true
|
||||
if (error instanceof TypeError) return true
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
||||
|
||||
const message = error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: typeof error === "string"
|
||||
? error.toLowerCase()
|
||||
: ""
|
||||
|
||||
return message.includes("timeout")
|
||||
|| message.includes("timed out")
|
||||
|| message.includes("failed to fetch")
|
||||
|| message.includes("networkerror")
|
||||
|| message.includes("network error")
|
||||
|| message.includes("gateway timeout")
|
||||
|| message.includes("econnreset")
|
||||
|| message.includes("socket hang up")
|
||||
}
|
||||
|
||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
||||
@@ -722,6 +759,20 @@ export async function optimisticSend(input: {
|
||||
try {
|
||||
await input.send(messageID)
|
||||
} catch (error) {
|
||||
const acceptedRecords = isAmbiguousSendFailure(error)
|
||||
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
|
||||
: null
|
||||
|
||||
if (acceptedRecords) {
|
||||
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
|
||||
_optimisticConfirm?.({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
messageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Rollback via optimistic infrastructure
|
||||
_optimisticRemove({
|
||||
sessionID: input.sessionId,
|
||||
@@ -739,6 +790,60 @@ export async function optimisticSend(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentSendConfirmationRecords(
|
||||
sessionId: string,
|
||||
messageID: string,
|
||||
directory?: string | null,
|
||||
): Promise<Array<{ info: Message; parts?: Part[] }> | null> {
|
||||
for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) {
|
||||
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS)
|
||||
try {
|
||||
const result = await sdk().session.messages({
|
||||
sessionID: sessionId,
|
||||
directory: directory ?? undefined,
|
||||
limit: SEND_CONFIRMATION_REFETCH_LIMIT,
|
||||
})
|
||||
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||
.filter((record: { info?: { id?: string } }) => !!record?.info?.id) as Array<{ info: Message; parts?: Part[] }>
|
||||
if (records.some((record) => record.info.id === messageID)) {
|
||||
return records
|
||||
}
|
||||
} catch {
|
||||
// Confirmation is best-effort; if it fails, keep the original send error path.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function materializeConfirmedSendRecords(
|
||||
store: DirectoryStoreApi,
|
||||
sessionId: string,
|
||||
messageID: string,
|
||||
records: Array<{ info: Message; parts?: Part[] }>,
|
||||
): void {
|
||||
store.setState((state) => {
|
||||
const currentMessages = state.message[sessionId]
|
||||
const message = { ...state.message }
|
||||
const part = { ...state.part }
|
||||
if (currentMessages) {
|
||||
const nextMessages = currentMessages.filter((message) => message.id !== messageID)
|
||||
message[sessionId] = nextMessages
|
||||
}
|
||||
delete part[messageID]
|
||||
|
||||
const materialized = materializeSessionSnapshots(
|
||||
{ ...state, message, part },
|
||||
sessionId,
|
||||
records.map((record) => ({
|
||||
info: stripMessageDiffSnapshots(record.info),
|
||||
parts: record.parts ?? [],
|
||||
})),
|
||||
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
|
||||
)
|
||||
return { message: materialized.message, part: materialized.part }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Abort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -611,6 +611,13 @@ export function useSync() {
|
||||
[clearOptimistic, getOptimisticStore],
|
||||
)
|
||||
|
||||
const optimisticConfirm = useCallback(
|
||||
(input: { sessionID: string; directory?: string | null; messageID: string }) => {
|
||||
clearOptimistic(input.sessionID, input.messageID, input.directory)
|
||||
},
|
||||
[clearOptimistic],
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
ensureSessionRenderable: syncSession,
|
||||
@@ -622,8 +629,9 @@ export function useSync() {
|
||||
optimistic: {
|
||||
add: optimisticAdd,
|
||||
remove: optimisticRemove,
|
||||
confirm: optimisticConfirm,
|
||||
},
|
||||
}),
|
||||
[syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove],
|
||||
[syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user