diff --git a/src/background/index.mjs b/src/background/index.mjs
index 73059d31d..89a1ea048 100644
--- a/src/background/index.mjs
+++ b/src/background/index.mjs
@@ -48,6 +48,28 @@ import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs
import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs'
import { isUsingModelName } from '../utils/model-name-convert.mjs'
import { redactSensitiveFields } from './redact.mjs'
+import {
+ clearProxyReconnectErrorSuppression,
+ consumeProxyReconnectErrorSuppression,
+ forwardProxyMessage,
+ interruptProxyGeneration,
+ isCurrentProxyGenerationMessage,
+ markProxyGenerationFinished,
+ markProxyGenerationFinishedFromMessage,
+ markProxyGenerationStarted,
+ shouldSkipProxyReconnect,
+ tagProxyRequestGeneration,
+} from './proxy-generation-state.mjs'
+
+function postProxySession(port, session, requestGenerationId) {
+ const proxyGenerationId = (port._proxyGenerationId ?? 0) + 1
+ port.proxy.postMessage({
+ session,
+ proxyGenerationId,
+ ...(requestGenerationId === undefined ? {} : { requestGenerationId }),
+ })
+ markProxyGenerationStarted(port, proxyGenerationId, requestGenerationId)
+}
const RECONNECT_CONFIG = {
MAX_ATTEMPTS: 5,
@@ -111,6 +133,11 @@ function setPortProxy(port, proxyTabId) {
console.debug('[background] Main port closed; skipping proxy message.')
return
}
+ if (!isCurrentProxyGenerationMessage(port, msg)) {
+ console.debug('[background] Ignoring a message from a superseded proxy generation.')
+ return
+ }
+ markProxyGenerationFinishedFromMessage(port, msg)
try {
port.postMessage(msg)
} catch (e) {
@@ -126,27 +153,30 @@ function setPortProxy(port, proxyTabId) {
console.debug('[background] Message to proxy tab (redacted):', redactedMsg)
if (port.proxy) {
try {
- port.proxy.postMessage(msg)
+ forwardProxyMessage(port, msg)
} catch (e) {
console.error(
'[background] Error posting message to proxy tab in _portOnMessage:',
e,
redactedMsg,
)
- try {
- // Attempt to notify the original sender about the failure
- port.postMessage({
- error:
- 'Failed to forward message to target tab. Tab might be closed or an extension error occurred.',
- })
- } catch (notifyError) {
- console.error(
- '[background] Error sending forwarding failure notification back to original sender:',
- notifyError,
- )
+ if (!msg?.stop) {
+ try {
+ // Attempt to notify the original sender about the failure
+ port.postMessage({
+ error:
+ 'Failed to forward message to target tab. Tab might be closed or an extension error occurred.',
+ })
+ } catch (notifyError) {
+ console.error(
+ '[background] Error sending forwarding failure notification back to original sender:',
+ notifyError,
+ )
+ }
}
}
} else {
+ if (msg?.stop) interruptProxyGeneration(port)
console.warn('[background] Port proxy not available to send message:', redactedMsg)
}
}
@@ -157,6 +187,17 @@ function setPortProxy(port, proxyTabId) {
const proxyRef = port.proxy
port.proxy = null
port._proxyTabId = null
+ if (interruptProxyGeneration(port)) {
+ if (!port._isClosed) {
+ try {
+ port.postMessage(
+ tagProxyRequestGeneration(port, { done: true, proxyDisconnected: true }),
+ )
+ } catch (e) {
+ console.warn('[background] Error posting done on proxy disconnect:', e)
+ }
+ }
+ }
if (port._reconnectTimerId) {
clearTimeout(port._reconnectTimerId)
port._reconnectTimerId = null
@@ -208,12 +249,20 @@ function setPortProxy(port, proxyTabId) {
console.warn('[background] Error removing _portOnDisconnect on max retries:', e)
}
}
- try {
- port.postMessage({
- error: `Connection to ChatGPT tab lost after ${RECONNECT_CONFIG.MAX_ATTEMPTS} attempts. Please refresh the page.`,
- })
- } catch (e) {
- console.warn('[background] Error sending final error message on max retries:', e)
+ if (consumeProxyReconnectErrorSuppression(port)) {
+ console.debug(
+ '[background] Skipping reconnect error because the interrupted generation was already completed.',
+ )
+ } else {
+ try {
+ port.postMessage(
+ tagProxyRequestGeneration(port, {
+ error: `Connection to ChatGPT tab lost after ${RECONNECT_CONFIG.MAX_ATTEMPTS} attempts. Please refresh the page.`,
+ }),
+ )
+ } catch (e) {
+ console.warn('[background] Error sending final error message on max retries:', e)
+ }
}
return
}
@@ -242,6 +291,10 @@ function setPortProxy(port, proxyTabId) {
)
return
}
+ if (shouldSkipProxyReconnect(port)) {
+ console.debug('[background] Proxy already replaced; skipping stale reconnect callback.')
+ return
+ }
console.debug(
`[background] Retrying connection to tab ${proxyTabId}, attempt ${port._reconnectAttempts}.`,
)
@@ -258,6 +311,7 @@ function setPortProxy(port, proxyTabId) {
'[background] Main port disconnected (e.g. popup/sidebar closed). Cleaning up proxy connections and listeners.',
)
port._isClosed = true
+ markProxyGenerationFinished(port)
if (port._reconnectTimerId) {
clearTimeout(port._reconnectTimerId)
port._reconnectTimerId = null
@@ -330,6 +384,7 @@ function setPortProxy(port, proxyTabId) {
port._reconnectAttempts = 0
console.debug('[background] Reset reconnect attempts after stable proxy connection.')
}
+ clearProxyReconnectErrorSuppression(port)
}, RECONNECT_CONFIG.STABLE_CONNECT_RESET_DELAY_MS)
} catch (error) {
console.error(`[background] Error in setPortProxy for tab ${proxyTabId}:`, error)
@@ -351,7 +406,14 @@ function isUsingOpenAICompatibleApiSession(session) {
)
}
-async function executeApi(session, port, config) {
+async function executeApi(
+ session,
+ port,
+ config,
+ isLatestSessionRequest = () => true,
+ requestGenerationId,
+ connectionPort = port,
+) {
console.log(
`[background] executeApi called for model: ${session.modelName}, apiMode: ${session.apiMode}`,
)
@@ -384,25 +446,30 @@ async function executeApi(session, port, config) {
}
}
if (tabId) {
+ const proxyPort = connectionPort
+ if (!isLatestSessionRequest()) {
+ console.debug('[background] Skipping a superseded ChatGPT Web session request.')
+ return
+ }
console.debug(`[background] ChatGPT Tab ID ${tabId} found.`)
- const hasMatchingProxy = Boolean(port.proxy && port._proxyTabId === tabId)
+ const hasMatchingProxy = Boolean(proxyPort.proxy && proxyPort._proxyTabId === tabId)
if (!hasMatchingProxy) {
- if (port.proxy) {
+ if (proxyPort.proxy) {
console.debug(
- `[background] Existing proxy tab ${port._proxyTabId} does not match ${tabId}; reconnecting.`,
+ `[background] Existing proxy tab ${proxyPort._proxyTabId} does not match ${tabId}; reconnecting.`,
)
} else {
console.debug('[background] port.proxy not found, calling setPortProxy.')
}
- setPortProxy(port, tabId)
+ setPortProxy(proxyPort, tabId)
}
- if (port.proxy && port._proxyTabId === tabId) {
+ if (proxyPort.proxy && proxyPort._proxyTabId === tabId) {
if (hasMatchingProxy) {
console.debug('[background] Proxy already established; forwarding session.')
}
console.debug('[background] Posting message to proxy tab:', { session: redactedSession })
try {
- port.proxy.postMessage({ session })
+ postProxySession(proxyPort, session, requestGenerationId)
} catch (e) {
console.warn(
'[background] Error posting message to existing proxy tab in executeApi (ChatGPT Web Model):',
@@ -410,11 +477,11 @@ async function executeApi(session, port, config) {
'. Attempting to reconnect.',
{ session: redactedSession },
)
- setPortProxy(port, tabId)
- if (port.proxy) {
+ setPortProxy(proxyPort, tabId)
+ if (proxyPort.proxy) {
console.debug('[background] Proxy re-established. Attempting to post message again.')
try {
- port.proxy.postMessage({ session })
+ postProxySession(proxyPort, session, requestGenerationId)
console.info('[background] Successfully posted session after proxy reconnection.')
} catch (e2) {
console.error(
@@ -469,11 +536,16 @@ async function executeApi(session, port, config) {
} else {
console.debug('[background] No valid ChatGPT Tab ID found. Using direct API call.')
const accessToken = await getChatGptAccessToken()
+ if (!isLatestSessionRequest()) {
+ console.debug('[background] Skipping a superseded direct ChatGPT Web session request.')
+ return
+ }
await generateAnswersWithChatgptWebApi(port, session.question, session, accessToken)
}
} else if (isUsingClaudeWebModel(session)) {
console.debug('[background] Using Claude Web Model')
const sessionKey = await getClaudeSessionKey()
+ if (!isLatestSessionRequest()) return
await generateAnswersWithClaudeWebApi(port, session.question, session, sessionKey)
} else if (isUsingMoonshotWebModel(session)) {
console.debug('[background] Using Moonshot Web Model')
@@ -481,6 +553,7 @@ async function executeApi(session, port, config) {
} else if (isUsingBingWebModel(session)) {
console.debug('[background] Using Bing Web Model')
const accessToken = await getBingAccessToken()
+ if (!isLatestSessionRequest()) return
if (isUsingModelName('bingFreeSydney', session)) {
console.debug('[background] Using Bing Free Sydney model')
await generateAnswersWithBingWebApi(port, session.question, session, accessToken, true)
@@ -490,7 +563,14 @@ async function executeApi(session, port, config) {
} else if (isUsingGeminiWebModel(session)) {
console.debug('[background] Using Gemini Web Model')
const cookies = await getBardCookies()
- await generateAnswersWithBardWebApi(port, session.question, session, cookies)
+ if (!isLatestSessionRequest()) return
+ await generateAnswersWithBardWebApi(
+ port,
+ session.question,
+ session,
+ cookies,
+ isLatestSessionRequest,
+ )
} else if (isUsingOpenAICompatibleApiSession(session)) {
console.debug('[background] Using OpenAI-compatible API provider')
await generateAnswersWithOpenAICompatibleApi(port, session.question, session, config)
@@ -935,12 +1015,21 @@ try {
}
try {
- registerPortListener(async (session, port, config) => {
- console.debug(
- `[background] Port listener triggered for session: ${session.modelName}, port: ${port.name}`,
- )
- await executeApi(session, port, config)
- })
+ registerPortListener(
+ async (session, port, config, isLatestSessionRequest, requestGenerationId, connectionPort) => {
+ console.debug(
+ `[background] Port listener triggered for session: ${session.modelName}, port: ${port.name}`,
+ )
+ await executeApi(
+ session,
+ port,
+ config,
+ isLatestSessionRequest,
+ requestGenerationId,
+ connectionPort,
+ )
+ },
+ )
console.log('[background] Port listener registered successfully.')
} catch (error) {
console.error('[background] Error registering port listener:', error)
diff --git a/src/background/proxy-generation-state.mjs b/src/background/proxy-generation-state.mjs
new file mode 100644
index 000000000..8638eb7c8
--- /dev/null
+++ b/src/background/proxy-generation-state.mjs
@@ -0,0 +1,64 @@
+export function markProxyGenerationStarted(port, proxyGenerationId, requestGenerationId) {
+ if (proxyGenerationId !== undefined) port._proxyGenerationId = proxyGenerationId
+ port._proxyRequestGenerationId = requestGenerationId
+ port._generating = true
+ port._suppressReconnectError = false
+}
+
+export function tagProxyRequestGeneration(port, message) {
+ return port._proxyRequestGenerationId === undefined
+ ? message
+ : { ...message, requestGenerationId: port._proxyRequestGenerationId }
+}
+
+export function isCurrentProxyGenerationMessage(port, message) {
+ return (
+ message.stoppedGenerationId !== undefined ||
+ port._proxyGenerationId === undefined ||
+ (port._generating && message.proxyGenerationId === port._proxyGenerationId)
+ )
+}
+
+export function markProxyGenerationFinished(port) {
+ port._generating = false
+ port._suppressReconnectError = false
+}
+
+export function forwardProxyMessage(port, message) {
+ const forwardedMessage =
+ message?.stop && port._stopAcknowledged ? { ...message, stopAcknowledged: true } : message
+ if (message?.stop) interruptProxyGeneration(port)
+ port.proxy.postMessage(forwardedMessage)
+}
+
+export function markProxyGenerationFinishedFromMessage(port, message) {
+ if (
+ !isCurrentProxyGenerationMessage(port, message) ||
+ message.stoppedGenerationId !== undefined ||
+ (!message.error && !message.done)
+ )
+ return false
+ markProxyGenerationFinished(port)
+ return true
+}
+
+export function interruptProxyGeneration(port) {
+ if (!port._generating) return false
+ port._generating = false
+ port._suppressReconnectError = true
+ return true
+}
+
+export function clearProxyReconnectErrorSuppression(port) {
+ port._suppressReconnectError = false
+}
+
+export function consumeProxyReconnectErrorSuppression(port) {
+ const shouldSuppress = Boolean(port._suppressReconnectError)
+ port._suppressReconnectError = false
+ return shouldSuppress
+}
+
+export function shouldSkipProxyReconnect(port) {
+ return port._isClosed || Boolean(port.proxy)
+}
diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx
index ca8765af4..4f8daeb48 100644
--- a/src/components/ConversationCard/index.jsx
+++ b/src/components/ConversationCard/index.jsx
@@ -40,6 +40,15 @@ import {
getApiModeDisplayLabel,
getConversationAiName,
} from '../../popup/sections/api-modes-provider-utils.mjs'
+import {
+ createConversationPortMessage,
+ createRetrySession,
+ finalizeInterruptedSession,
+ getCompletedAnswerUpdate,
+ getInterruptedCompletionState,
+ isSupersededGenerationMessage,
+ isSupersededRequestMessage,
+} from './session.mjs'
const logo = Browser.runtime.getURL('logo.png')
const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__'
@@ -66,6 +75,11 @@ function ConversationCard(props) {
const [session, setSession] = useState(props.session)
const windowSize = useClampWindowSize([750, 1500], [250, 1100])
const bodyRef = useRef(null)
+ const replacedPortRef = useRef(null)
+ const partialAnswerRef = useRef('')
+ const retryRecordRef = useRef(null)
+ const retryGenerationIdRef = useRef(0)
+ const requestGenerationIdRef = useRef(0)
const [completeDraggable, setCompleteDraggable] = useState(false)
const useForegroundFetch = isUsingBingWebModel(session)
const [apiModes, setApiModes] = useState([])
@@ -118,7 +132,7 @@ function ConversationCard(props) {
useEffect(() => {
if (props.onUpdate) props.onUpdate(port, session, conversationItemData)
- }, [session, conversationItemData])
+ }, [port, session, conversationItemData])
useEffect(() => {
const { offsetHeight, scrollHeight, scrollTop } = bodyRef.current
@@ -137,6 +151,8 @@ function ConversationCard(props) {
// when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request
if (props.question && triggered) {
const newSession = initSession({ ...session, question: props.question })
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
setSession(newSession)
await postMessage({ session: newSession })
}
@@ -172,18 +188,34 @@ function ConversationCard(props) {
}
const portMessageListener = (msg) => {
+ if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return
+ if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return
+
if (msg.answer) {
+ partialAnswerRef.current = msg.answer
updateAnswer(msg.answer, false, 'answer')
}
if (msg.session) {
- if (msg.done) msg.session = { ...msg.session, isRetry: false }
- setSession(msg.session)
+ setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session)
}
if (msg.done) {
- updateAnswer('', true, 'answer', true)
+ const partialAnswer = partialAnswerRef.current
+ const retryRecord = retryRecordRef.current
+ const completionState = getInterruptedCompletionState(msg, partialAnswer, retryRecord)
+ if (completionState.shouldFinalize) {
+ setSession((currentSession) =>
+ finalizeInterruptedSession(currentSession, partialAnswer, retryRecord),
+ )
+ }
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
+ const answerUpdate = getCompletedAnswerUpdate(completionState.restoredRetryAnswer)
+ updateAnswer(answerUpdate.value, answerUpdate.appended, 'answer', true)
setIsReady(true)
}
if (msg.error) {
+ const retryRecord = retryRecordRef.current
+ setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord))
switch (msg.error) {
case 'UNAUTHORIZED':
updateAnswer(
@@ -233,6 +265,8 @@ function ConversationCard(props) {
break
}
}
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
setIsReady(true)
}
}
@@ -242,24 +276,26 @@ function ConversationCard(props) {
/**
* @param {Session|undefined} session
* @param {boolean|undefined} stop
+ * @param {number|undefined} stopGenerationId
*/
- const postMessage = async ({ session, stop }) => {
+ const postMessage = async ({ session, stop, stopGenerationId }) => {
+ const requestGenerationId = session ? ++requestGenerationIdRef.current : undefined
if (useForegroundFetch) {
- foregroundMessageListeners.current.forEach((listener) => listener({ session, stop }))
+ foregroundMessageListeners.current.forEach((listener) =>
+ listener({ session, stop, stopGenerationId, requestGenerationId }),
+ )
if (session) {
const fakePort = {
postMessage: (msg) => {
- portMessageListener(msg)
+ portMessageListener({ ...msg, requestGenerationId })
},
onMessage: {
addListener: (listener) => {
foregroundMessageListeners.current.push(listener)
},
removeListener: (listener) => {
- foregroundMessageListeners.current.splice(
- foregroundMessageListeners.current.indexOf(listener),
- 1,
- )
+ const index = foregroundMessageListeners.current.indexOf(listener)
+ if (index !== -1) foregroundMessageListeners.current.splice(index, 1)
},
},
onDisconnect: {
@@ -283,12 +319,23 @@ function ConversationCard(props) {
}
}
} else {
- port.postMessage({ session, stop })
+ port.postMessage(
+ createConversationPortMessage({
+ session,
+ stop,
+ stopGenerationId,
+ requestGenerationId,
+ }),
+ )
}
}
useEffect(() => {
const portListener = () => {
+ if (replacedPortRef.current === port) {
+ replacedPortRef.current = null
+ return
+ }
setPort(Browser.runtime.connect())
setIsReady(true)
}
@@ -329,33 +376,44 @@ function ConversationCard(props) {
port.onMessage.removeListener(portMessageListener)
}
}
- }, [conversationItemData])
+ }, [port, conversationItemData])
const getRetryFn = (session) => async () => {
updateAnswer(`
${t('Waiting for response...')}
`, false, 'answer')
setIsReady(false)
- if (session.conversationRecords.length > 0) {
- const lastRecord = session.conversationRecords[session.conversationRecords.length - 1]
+ const conversationRecords = session.conversationRecords.map((record) => ({ ...record }))
+ if (retryRecordRef.current === null && conversationRecords.length > 0) {
+ const lastRecord = conversationRecords[conversationRecords.length - 1]
if (
conversationItemData[conversationItemData.length - 1].done &&
conversationItemData.length > 1 &&
lastRecord.question === conversationItemData[conversationItemData.length - 2].content
) {
- session.conversationRecords.pop()
+ retryRecordRef.current = conversationRecords.pop()
}
}
- const newSession = { ...session, isRetry: true }
+ const newSession = createRetrySession(session, conversationRecords, retryRecordRef.current)
setSession(newSession)
try {
- await postMessage({ stop: true })
+ partialAnswerRef.current = ''
+ if (!isReady) {
+ ++requestGenerationIdRef.current
+ const stopGenerationId = ++retryGenerationIdRef.current
+ await postMessage({ stop: true, stopGenerationId })
+ }
await postMessage({ session: newSession })
} catch (e) {
+ const retryRecord = retryRecordRef.current
+ setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord))
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
updateAnswer(e, false, 'error')
+ setIsReady(true)
}
}
- const retryFn = useMemo(() => getRetryFn(session), [session])
+ const retryFn = useMemo(() => getRetryFn(session), [session, isReady, conversationItemData, port])
return (
@@ -492,7 +550,23 @@ function ConversationCard(props) {
size={16}
text={t('Clear Conversation')}
onConfirm={async () => {
- await postMessage({ stop: true })
+ ++requestGenerationIdRef.current
+ const stopGenerationId = ++retryGenerationIdRef.current
+ try {
+ await postMessage({ stop: true, stopGenerationId })
+ } catch (error) {
+ console.warn(
+ '[ConversationCard] Failed to stop generation before clearing conversation:',
+ error,
+ )
+ }
+ if (!useForegroundFetch) {
+ replacedPortRef.current = port
+ port.disconnect()
+ setPort(Browser.runtime.connect())
+ }
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
Browser.runtime.sendMessage({
type: 'DELETE_CONVERSATION',
data: {
@@ -507,6 +581,7 @@ function ConversationCard(props) {
})
newSession.sessionId = session.sessionId
setSession(newSession)
+ setIsReady(true)
}}
/>
{!props.pageMode && (
@@ -616,6 +691,8 @@ function ConversationCard(props) {
'answer',
`
${t('Waiting for response...')}
`,
)
+ partialAnswerRef.current = ''
+ retryRecordRef.current = null
setConversationItemData([...conversationItemData, newQuestion, newAnswer])
setIsReady(false)
diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs
new file mode 100644
index 000000000..40c60f166
--- /dev/null
+++ b/src/components/ConversationCard/session.mjs
@@ -0,0 +1,79 @@
+import { pushRecord } from '../../services/apis/shared.mjs'
+
+export function finalizeInterruptedSession(session, answer, retryRecord = null) {
+ if (!answer) {
+ if (!session.isRetry && !retryRecord) return session
+ const lastRecord = session.conversationRecords.at(-1)
+ const shouldRestoreRetryRecord =
+ retryRecord &&
+ (lastRecord?.question !== retryRecord.question || lastRecord?.answer !== retryRecord.answer)
+ return {
+ ...session,
+ conversationRecords: shouldRestoreRetryRecord
+ ? [...session.conversationRecords, { ...retryRecord }]
+ : session.conversationRecords,
+ isRetry: false,
+ }
+ }
+ const updatedSession = {
+ ...session,
+ conversationRecords: session.conversationRecords.map((record) => ({ ...record })),
+ }
+ pushRecord(updatedSession, session.question, answer)
+ updatedSession.isRetry = false
+ return updatedSession
+}
+
+export function isSupersededGenerationMessage(message, latestSupersededGenerationId) {
+ return (
+ message.stoppedGenerationId !== undefined &&
+ message.stoppedGenerationId <= latestSupersededGenerationId
+ )
+}
+
+export function isSupersededRequestMessage(message, currentRequestGenerationId) {
+ return (
+ message.requestGenerationId !== undefined &&
+ message.requestGenerationId !== currentRequestGenerationId
+ )
+}
+
+export function createConversationPortMessage({
+ session,
+ stop,
+ stopGenerationId,
+ requestGenerationId,
+}) {
+ return {
+ session,
+ stop,
+ ...(stopGenerationId === undefined ? {} : { stopGenerationId }),
+ ...(requestGenerationId === undefined ? {} : { requestGenerationId }),
+ }
+}
+
+export function createRetrySession(session, conversationRecords, retryRecord) {
+ return {
+ ...session,
+ conversationRecords,
+ isRetry: retryRecord === null,
+ }
+}
+
+export function getCompletedAnswerUpdate(restoredRetryAnswer) {
+ return {
+ value: restoredRetryAnswer ?? '',
+ appended: restoredRetryAnswer === null,
+ }
+}
+
+export function getInterruptedCompletionState(message, partialAnswer, retryRecord) {
+ const shouldFinalize = Boolean(
+ message.proxyDisconnected || (!message.session && (partialAnswer || retryRecord)),
+ )
+ return {
+ shouldFinalize,
+ restoredRetryAnswer:
+ shouldFinalize && !partialAnswer && retryRecord ? retryRecord.answer : null,
+ }
+}
diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx
index ea70dfbd2..07320a240 100644
--- a/src/content-script/index.jsx
+++ b/src/content-script/index.jsx
@@ -872,7 +872,7 @@ function ensureChatGptPortListenerRegistered() {
try {
console.log('[content] Attempting to register port listener for chatgpt.com.')
- registerPortListener(async (session, port) => {
+ registerPortListener(async (session, port, _config, isLatestSessionRequest) => {
console.debug(
`[content] Port listener callback triggered. Session model: ${session?.modelName}, Port: ${port.name}`,
)
@@ -883,6 +883,10 @@ function ensureChatGptPortListenerRegistered() {
session.question,
)
const accessToken = await getChatGptAccessToken()
+ if (!isLatestSessionRequest()) {
+ console.debug('[content] Skipping a superseded ChatGPT Web session request.')
+ return
+ }
if (!accessToken) {
console.warn('[content] No ChatGPT access token available for web API call.')
port.postMessage({ error: 'Missing ChatGPT access token.' })
@@ -896,6 +900,10 @@ function ensureChatGptPortListenerRegistered() {
)
}
} catch (e) {
+ if (!isLatestSessionRequest()) {
+ console.debug('[content] Ignoring an error from a superseded session request.')
+ return
+ }
console.error('[content] Error in port listener callback:', e, 'Session:', session)
try {
port.postMessage({
diff --git a/src/services/apis/azure-openai-api.mjs b/src/services/apis/azure-openai-api.mjs
index 40585348d..a8e8eaf80 100644
--- a/src/services/apis/azure-openai-api.mjs
+++ b/src/services/apis/azure-openai-api.mjs
@@ -68,10 +68,15 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session)
}
},
async onStart() {},
- async onEnd() {
- port.postMessage({ done: true })
- port.onMessage.removeListener(messageListener)
- port.onDisconnect.removeListener(disconnectListener)
+ async onEnd(aborted) {
+ try {
+ if (!aborted) {
+ port.postMessage({ done: true })
+ }
+ } finally {
+ port.onMessage.removeListener(messageListener)
+ port.onDisconnect.removeListener(disconnectListener)
+ }
},
async onError(resp) {
port.onMessage.removeListener(messageListener)
diff --git a/src/services/apis/bard-web.mjs b/src/services/apis/bard-web.mjs
index 95445ffce..8e2dd7069 100644
--- a/src/services/apis/bard-web.mjs
+++ b/src/services/apis/bard-web.mjs
@@ -1,19 +1,27 @@
import { pushRecord } from './shared.mjs'
-import Bard from '../clients/bard'
+import Bard from '../clients/bard/index.mjs'
/**
* @param {Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} cookies
+ * @param {() => boolean} isLatestSessionRequest
*/
-export async function generateAnswersWithBardWebApi(port, question, session, cookies) {
+export async function generateAnswersWithBardWebApi(
+ port,
+ question,
+ session,
+ cookies,
+ isLatestSessionRequest = () => true,
+) {
// const { controller, messageListener, disconnectListener } = setAbortController(port)
const bot = new Bard(cookies)
// eslint-disable-next-line
try {
const { answer, conversationObj } = await bot.ask(question, session.bard_conversationObj || {})
+ if (!isLatestSessionRequest()) return
session.bard_conversationObj = conversationObj
pushRecord(session, question, answer)
console.debug('conversation history', { content: session.conversationRecords })
diff --git a/src/services/apis/chatgpt-web.mjs b/src/services/apis/chatgpt-web.mjs
index 8684e9cdc..37f63d518 100644
--- a/src/services/apis/chatgpt-web.mjs
+++ b/src/services/apis/chatgpt-web.mjs
@@ -212,16 +212,34 @@ export async function registerWebsocket(accessToken) {
* @param {string} accessToken
*/
export async function generateAnswersWithChatgptWebApi(port, question, session, accessToken) {
- const { controller, cleanController } = setAbortController(
+ let wsCallback
+ let cleanController = () => {}
+ const removeWebsocketCallback = () => {
+ if (!wsCallback) return
+ wsCallbacks = wsCallbacks.filter((callback) => callback !== wsCallback)
+ wsCallback = null
+ }
+ const stopWebsocketRequest = (conversationId, wsRequestId) => {
+ if (!wsRequestId) return
+ stopWebsocketConversation(accessToken, conversationId, wsRequestId).catch((error) => {
+ console.warn('[chatgpt-web] Failed to stop WebSocket conversation:', error)
+ })
+ }
+ const abortControllerState = setAbortController(
port,
() => {
- if (session.wsRequestId)
- stopWebsocketConversation(accessToken, session.conversationId, session.wsRequestId)
+ stopWebsocketRequest(session.conversationId, session.wsRequestId)
+ removeWebsocketCallback()
+ cleanController()
},
() => {
+ removeWebsocketCallback()
+ cleanController()
if (session.autoClean) deleteConversation(accessToken, session.conversationId)
},
)
+ const { controller } = abortControllerState
+ cleanController = abortControllerState.cleanController
const config = await getUserConfig()
let arkoseError
@@ -320,8 +338,16 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
let generatedImageUrl = ''
if (useWebsocket) {
+ if (controller.signal.aborted) {
+ cleanController()
+ return
+ }
await registerWebsocket(accessToken)
- const wsCallback = async (event) => {
+ if (controller.signal.aborted) {
+ cleanController()
+ return
+ }
+ wsCallback = async (event) => {
let wsData
try {
wsData = JSON.parse(event.data)
@@ -343,7 +369,6 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
console.debug('ws message', '[DONE]')
if (wsData.conversation_id === session.conversationId) {
finishMessage()
- wsCallbacks = wsCallbacks.filter((cb) => cb !== wsCallback)
}
} else {
console.debug('json error', error)
@@ -352,50 +377,73 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
}
}
wsCallbacks.push(wsCallback)
- const { conversationId, wsRequestId } = await sendWebsocketConversation(accessToken, options)
- session.conversationId = conversationId
- session.wsRequestId = wsRequestId
- port.postMessage({ session: session })
- } else {
- await fetchSSE(url, {
- ...options,
- onMessage(message) {
- console.debug('sse message', message)
- if (message.trim() === '[DONE]') {
- finishMessage()
- return
- }
- let data
- try {
- data = JSON.parse(message)
- } catch (error) {
- console.debug('json error', error)
- return
- }
- handleMessage(data)
- },
- async onStart() {
- // sendModerations(accessToken, question, session.conversationId, session.messageId)
- },
- async onEnd() {
- port.postMessage({ done: true })
- cleanController()
- },
- async onError(resp) {
+ try {
+ const { conversationId, wsRequestId } = await sendWebsocketConversation(accessToken, options)
+ if (controller.signal.aborted) {
+ removeWebsocketCallback()
cleanController()
- if (resp instanceof Error) throw resp
- if (resp.status === 403) {
- throw new Error('CLOUDFLARE')
- }
- const error = await resp.json().catch(() => ({}))
- throw new Error(
- !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`,
- )
- },
- })
+ stopWebsocketRequest(conversationId, wsRequestId)
+ return
+ }
+ session.conversationId = conversationId
+ session.wsRequestId = wsRequestId
+ port.postMessage({ session: session })
+ } catch (error) {
+ removeWebsocketCallback()
+ cleanController()
+ if (controller.signal.aborted) return
+ throw error
+ }
+ } else {
+ try {
+ await fetchSSE(url, {
+ ...options,
+ onMessage(message) {
+ console.debug('sse message', message)
+ if (message.trim() === '[DONE]') {
+ finishMessage()
+ return
+ }
+ let data
+ try {
+ data = JSON.parse(message)
+ } catch (error) {
+ console.debug('json error', error)
+ return
+ }
+ handleMessage(data)
+ },
+ async onStart() {
+ // sendModerations(accessToken, question, session.conversationId, session.messageId)
+ },
+ async onEnd(aborted) {
+ try {
+ if (!aborted) {
+ port.postMessage({ done: true })
+ }
+ } finally {
+ cleanController()
+ }
+ },
+ async onError(resp) {
+ cleanController()
+ if (resp instanceof Error) throw resp
+ if (resp.status === 403) {
+ throw new Error('CLOUDFLARE')
+ }
+ const error = await resp.json().catch(() => ({}))
+ throw new Error(
+ !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`,
+ )
+ },
+ })
+ } finally {
+ cleanController()
+ }
}
function handleMessage(data) {
+ if (controller.signal.aborted) return
if (data.error) {
throw new Error(JSON.stringify(data.error))
}
@@ -442,6 +490,9 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
}
function finishMessage() {
+ removeWebsocketCallback()
+ cleanController()
+ if (controller.signal.aborted) return
pushRecord(session, question, answer)
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: answer, done: true, session: session })
diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs
index 648720c01..341a4dd9d 100644
--- a/src/services/apis/claude-api.mjs
+++ b/src/services/apis/claude-api.mjs
@@ -78,10 +78,15 @@ export async function generateAnswersWithClaudeApi(port, question, session) {
}
},
async onStart() {},
- async onEnd() {
- port.postMessage({ done: true })
- port.onMessage.removeListener(messageListener)
- port.onDisconnect.removeListener(disconnectListener)
+ async onEnd(aborted) {
+ try {
+ if (!aborted) {
+ port.postMessage({ done: true })
+ }
+ } finally {
+ port.onMessage.removeListener(messageListener)
+ port.onDisconnect.removeListener(disconnectListener)
+ }
},
async onError(resp) {
port.onMessage.removeListener(messageListener)
diff --git a/src/services/apis/claude-web.mjs b/src/services/apis/claude-web.mjs
index 2c5307f0f..0573b9c7b 100644
--- a/src/services/apis/claude-web.mjs
+++ b/src/services/apis/claude-web.mjs
@@ -9,9 +9,19 @@ import { getModelValue } from '../../utils/model-name-convert.mjs'
* @param {string} sessionKey
*/
export async function generateAnswersWithClaudeWebApi(port, question, session, sessionKey) {
- const bot = new Claude({ sessionKey })
- await bot.init()
const { controller, cleanController } = setAbortController(port)
+ let bot
+ try {
+ bot = new Claude({ sessionKey })
+ await bot.init()
+ } catch (error) {
+ cleanController()
+ throw error
+ }
+ if (controller.signal.aborted) {
+ cleanController()
+ return
+ }
const model = getModelValue(session)
let answer = ''
diff --git a/src/services/apis/moonshot-web.mjs b/src/services/apis/moonshot-web.mjs
index 2f6334386..2f2bfdd6d 100644
--- a/src/services/apis/moonshot-web.mjs
+++ b/src/services/apis/moonshot-web.mjs
@@ -1,6 +1,6 @@
import { pushRecord, setAbortController } from './shared.mjs'
import { setUserConfig } from '../../config/index.mjs'
-import { fetchSSE } from '../../utils/fetch-sse'
+import { fetchSSE } from '../../utils/fetch-sse.mjs'
import { isEmpty } from 'lodash-es'
import { getModelValue } from '../../utils/model-name-convert.mjs'
@@ -134,7 +134,7 @@ export class MoonshotWeb {
* @async
* @returns {Promise
} Void
*/
- async init() {
+ async init(signal) {
const response = this.request('/api/user', {
headers: {
accept: '*/*',
@@ -142,6 +142,7 @@ export class MoonshotWeb {
Origin: 'https://www.kimi.com',
},
method: 'GET',
+ signal,
})
if ((await response).status === 200) {
this.ready = true
@@ -153,6 +154,7 @@ export class MoonshotWeb {
Origin: 'https://www.kimi.com',
},
method: 'GET',
+ signal,
})
.then((r) => r.json())
.catch(errorHandle('get kimi.moonshoot.cn access_token'))
@@ -426,7 +428,11 @@ export class Conversation {
}
},
async onStart() {},
- async onEnd() {
+ async onEnd(aborted = false) {
+ if (aborted) {
+ reject(new DOMException('Aborted', 'AbortError'))
+ return
+ }
resolve({
completion: fullResponse,
})
@@ -579,9 +585,20 @@ export class Message {
* @param {UserConfig} config
*/
export async function generateAnswersWithMoonshotWebApi(port, question, session, config) {
- const bot = new MoonshotWeb({ config })
- await bot.init()
const { controller, cleanController } = setAbortController(port)
+ let bot
+ try {
+ bot = new MoonshotWeb({ config })
+ await bot.init(controller.signal)
+ } catch (error) {
+ cleanController()
+ if (controller.signal.aborted) return
+ throw error
+ }
+ if (controller.signal.aborted) {
+ cleanController()
+ return
+ }
const model = getModelValue(session)
let answer = ''
diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs
index 86d08500d..f65142667 100644
--- a/src/services/apis/openai-compatible-core.mjs
+++ b/src/services/apis/openai-compatible-core.mjs
@@ -60,7 +60,13 @@ export async function generateAnswersWithOpenAICompatible({
extraHeaders = {},
allowLegacyResponseField = false,
}) {
- const { controller, messageListener, disconnectListener } = setAbortController(port)
+ const {
+ controller,
+ messageListener,
+ disconnectListener,
+ getStopGenerationId,
+ isCurrentSessionRequest,
+ } = setAbortController(port)
let requestBody
const conversationRecords = Array.isArray(session.conversationRecords)
@@ -142,12 +148,34 @@ export async function generateAnswersWithOpenAICompatible({
}
},
async onStart() {},
- async onEnd() {
- if (!finished) {
- finish()
+ async onEnd(aborted = false) {
+ try {
+ if (!finished) {
+ if (aborted) {
+ const shouldPostSession = Boolean(answer) || session.isRetry
+ if (shouldPostSession && isCurrentSessionRequest()) {
+ if (answer) {
+ pushRecord(session, question, answer)
+ }
+ session.isRetry = false
+ try {
+ const stoppedGenerationId = getStopGenerationId()
+ port.postMessage({
+ session,
+ ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }),
+ })
+ } catch (e) {
+ console.warn('[openai-compatible-core] Failed to post session on abort:', e)
+ }
+ }
+ } else {
+ finish()
+ }
+ }
+ } finally {
+ port.onMessage.removeListener(messageListener)
+ port.onDisconnect.removeListener(disconnectListener)
}
- port.onMessage.removeListener(messageListener)
- port.onDisconnect.removeListener(disconnectListener)
},
async onError(resp) {
port.onMessage.removeListener(messageListener)
diff --git a/src/services/apis/shared.mjs b/src/services/apis/shared.mjs
index 77fcab618..a425305f1 100644
--- a/src/services/apis/shared.mjs
+++ b/src/services/apis/shared.mjs
@@ -15,13 +15,33 @@ export const getCustomApiPromptBase = async () => {
return `I am a helpful, creative, clever, and very friendly assistant. I am familiar with various languages in the world.`
}
+export function acknowledgePortStop(port, message) {
+ if (message.stopAcknowledged || port._stopAcknowledged) return false
+ try {
+ port.postMessage({
+ done: true,
+ ...(message.stopGenerationId === undefined
+ ? {}
+ : { stoppedGenerationId: message.stopGenerationId }),
+ })
+ } catch (e) {
+ return false
+ }
+ port._stopAcknowledged = true
+ message.stopAcknowledged = true
+ return true
+}
+
export function setAbortController(port, onStop, onDisconnect) {
const controller = new AbortController()
+ const sessionRequestGeneration = port._sessionRequestGeneration
+ let stopGenerationId
const messageListener = (msg) => {
if (msg.stop) {
+ stopGenerationId = msg.stopGenerationId
port.onMessage.removeListener(messageListener)
console.debug('stop generating')
- port.postMessage({ done: true })
+ acknowledgePortStop(port, msg)
controller.abort()
if (onStop) onStop()
}
@@ -45,7 +65,14 @@ export function setAbortController(port, onStop, onDisconnect) {
}
}
- return { controller, cleanController, messageListener, disconnectListener }
+ return {
+ controller,
+ cleanController,
+ messageListener,
+ disconnectListener,
+ getStopGenerationId: () => stopGenerationId,
+ isCurrentSessionRequest: () => port._sessionRequestGeneration === sessionRequestGeneration,
+ }
}
export function pushRecord(session, question, answer) {
diff --git a/src/services/apis/waylaidwanderer-api.mjs b/src/services/apis/waylaidwanderer-api.mjs
index d30603488..a07cdf1bf 100644
--- a/src/services/apis/waylaidwanderer-api.mjs
+++ b/src/services/apis/waylaidwanderer-api.mjs
@@ -63,10 +63,15 @@ export async function generateAnswersWithWaylaidwandererApi(port, question, sess
}
},
async onStart() {},
- async onEnd() {
- port.postMessage({ done: true })
- port.onMessage.removeListener(messageListener)
- port.onDisconnect.removeListener(disconnectListener)
+ async onEnd(aborted) {
+ try {
+ if (!aborted) {
+ port.postMessage({ done: true })
+ }
+ } finally {
+ port.onMessage.removeListener(messageListener)
+ port.onDisconnect.removeListener(disconnectListener)
+ }
},
async onError(resp) {
port.onMessage.removeListener(messageListener)
diff --git a/src/services/clients/claude/index.mjs b/src/services/clients/claude/index.mjs
index f21652fad..02b9b1117 100644
--- a/src/services/clients/claude/index.mjs
+++ b/src/services/clients/claude/index.mjs
@@ -627,7 +627,11 @@ export class Conversation {
}
},
async onStart() {},
- async onEnd() {
+ async onEnd(aborted = false) {
+ if (aborted) {
+ reject(new DOMException('Aborted', 'AbortError'))
+ return
+ }
resolve({
completion: fullResponse,
})
diff --git a/src/services/wrappers.mjs b/src/services/wrappers.mjs
index 71dce351a..163a73138 100644
--- a/src/services/wrappers.mjs
+++ b/src/services/wrappers.mjs
@@ -12,6 +12,7 @@ import {
modelNameToDesc,
normalizeApiMode,
} from '../utils/model-name-convert.mjs'
+import { acknowledgePortStop } from './apis/shared.mjs'
export async function getChatGptAccessToken() {
await clearOldAccessToken()
@@ -66,9 +67,31 @@ function isAbortError(err) {
return name === 'AbortError' || message.includes('aborted') || message.includes('aborterror')
}
+function isDisconnectedPortError(err) {
+ if (!err || typeof err !== 'object') return false
+ const message =
+ typeof err.message === 'string' ? err.message.trim().toLowerCase().replace(/\.$/, '') : ''
+ return (
+ message === 'attempting to use a disconnected port object' ||
+ message === 'attempt to postmessage on disconnected port' ||
+ message === 'extension context invalidated'
+ )
+}
+
export function handlePortError(session, port, err) {
if (isAbortError(err)) return
+ if (isDisconnectedPortError(err)) {
+ console.warn('[handlePortError] Ignoring disconnected port error:', err.message)
+ return
+ }
console.error(err)
+ const postError = (error) => {
+ try {
+ port.postMessage({ error })
+ } catch (postErr) {
+ console.warn('[handlePortError] Failed to post error:', postErr)
+ }
+ }
const message = typeof err?.message === 'string' ? err.message : ''
if (message) {
if (
@@ -76,46 +99,87 @@ export function handlePortError(session, port, err) {
message.includes(m),
)
)
- port.postMessage({ error: t('Exceeded maximum context length') + '\n\n' + message })
+ postError(t('Exceeded maximum context length') + '\n\n' + message)
else if (['CaptchaChallenge', 'CAPTCHA'].some((m) => message.includes(m)))
- port.postMessage({ error: t('Bing CaptchaChallenge') + '\n\n' + message })
+ postError(t('Bing CaptchaChallenge') + '\n\n' + message)
else if (['exceeded your current quota'].some((m) => message.includes(m)))
- port.postMessage({ error: t('Exceeded quota') + '\n\n' + message })
+ postError(t('Exceeded quota') + '\n\n' + message)
else if (['Rate limit reached'].some((m) => message.includes(m)))
- port.postMessage({ error: t('Rate limit') + '\n\n' + message })
+ postError(t('Rate limit') + '\n\n' + message)
else if (['authentication token has expired'].some((m) => message.includes(m)))
- port.postMessage({ error: 'UNAUTHORIZED' })
+ postError('UNAUTHORIZED')
else if (
isUsingClaudeWebModel(session) &&
['Invalid authorization', 'Session key required'].some((m) => message.includes(m))
)
- port.postMessage({
- error: t('Please login at https://claude.ai first, and then click the retry button'),
- })
+ postError(t('Please login at https://claude.ai first, and then click the retry button'))
else if (
isUsingBingWebModel(session) &&
['/turing/conversation/create: failed to parse response body.'].some((m) =>
message.includes(m),
)
)
- port.postMessage({ error: t('Please login at https://bing.com first') })
- else port.postMessage({ error: message })
+ postError(t('Please login at https://bing.com first'))
+ else postError(message)
} else {
const errMsg = JSON.stringify(err) ?? 'unknown error'
if (isUsingBingWebModel(session) && errMsg.includes('isTrusted'))
- port.postMessage({ error: t('Please login at https://bing.com first') })
- else port.postMessage({ error: errMsg })
+ postError(t('Please login at https://bing.com first'))
+ else postError(errMsg)
}
}
+export function claimLatestPortSessionRequest(port) {
+ const requestId = (port._latestSessionRequestId ?? 0) + 1
+ port._latestSessionRequestId = requestId
+ port._sessionRequestGeneration = (port._sessionRequestGeneration ?? 0) + 1
+ port._stopAcknowledged = false
+ return () => port._latestSessionRequestId === requestId
+}
+
+export function invalidateLatestPortSessionRequest(port) {
+ port._latestSessionRequestId = (port._latestSessionRequestId ?? 0) + 1
+}
+
+function createSessionRequestPort(port, proxyGenerationId, requestGenerationId) {
+ const sessionRequestGeneration = port._sessionRequestGeneration
+ return new Proxy(port, {
+ get(target, property, receiver) {
+ if (property === 'postMessage') {
+ return (message) => {
+ if (target._sessionRequestGeneration !== sessionRequestGeneration) return
+ target.postMessage({
+ ...message,
+ ...(proxyGenerationId === undefined ? {} : { proxyGenerationId }),
+ ...(requestGenerationId === undefined ? {} : { requestGenerationId }),
+ })
+ }
+ }
+ return Reflect.get(target, property, receiver)
+ },
+ })
+}
+
export function registerPortListener(executor) {
Browser.runtime.onConnect.addListener((port) => {
console.debug('connected')
const onMessage = async (msg) => {
console.debug('received msg', msg)
+ if (msg.stop) {
+ invalidateLatestPortSessionRequest(port)
+ acknowledgePortStop(port, msg)
+ return
+ }
const session = msg.session
if (!session) return
+ const isLatestSessionRequest = claimLatestPortSessionRequest(port)
+ const requestPort = createSessionRequestPort(
+ port,
+ msg.proxyGenerationId,
+ msg.requestGenerationId,
+ )
const config = await getUserConfig()
+ if (!isLatestSessionRequest()) return
if (!session.modelName) session.modelName = config.modelName
if (!session.apiMode && session.modelName !== 'customModel') session.apiMode = config.apiMode
if (session.apiMode) session.apiMode = normalizeApiMode(session.apiMode)
@@ -125,11 +189,18 @@ export function registerPortListener(executor) {
t,
config.customModelName,
)
- port.postMessage({ session })
+ requestPort.postMessage({ session })
try {
- await executor(session, port, config)
+ await executor(
+ session,
+ requestPort,
+ config,
+ isLatestSessionRequest,
+ msg.requestGenerationId,
+ port,
+ )
} catch (err) {
- handlePortError(session, port, err)
+ if (isLatestSessionRequest()) handlePortError(session, requestPort, err)
}
}
diff --git a/src/utils/fetch-sse.mjs b/src/utils/fetch-sse.mjs
index 059cc0430..06027cee3 100644
--- a/src/utils/fetch-sse.mjs
+++ b/src/utils/fetch-sse.mjs
@@ -1,11 +1,28 @@
import { createParser } from './eventsource-parser.mjs'
+function isAbortError(err) {
+ if (!err || typeof err !== 'object') return false
+ const name = typeof err.name === 'string' ? err.name : ''
+ return name === 'AbortError'
+}
+
export async function fetchSSE(resource, options) {
const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options
- const resp = await fetch(resource, fetchOptions).catch(async (err) => {
+ let resp
+ try {
+ resp = await fetch(resource, fetchOptions)
+ } catch (err) {
+ if (isAbortError(err)) {
+ try {
+ await onEnd(true)
+ } catch (e) {
+ console.warn('[fetch-sse] onEnd threw during abort:', e)
+ }
+ return
+ }
await onError(err)
- })
- if (!resp) return
+ return
+ }
if (!resp.ok) {
await onError(resp)
return
@@ -15,15 +32,42 @@ export async function fetchSSE(resource, options) {
onMessage(event.data)
}
})
+ const handleCallbackError = async (err) => {
+ await onError(err)
+ throw err
+ }
let hasStarted = false
const reader = resp.body.getReader()
let result
- while (!(result = await reader.read()).done) {
+ let done = false
+ while (!done) {
+ try {
+ result = await reader.read()
+ } catch (err) {
+ if (isAbortError(err)) {
+ try {
+ await onEnd(true)
+ } catch (e) {
+ console.warn('[fetch-sse] onEnd threw during abort:', e)
+ }
+ return
+ }
+ await onError(err)
+ return
+ }
+
+ done = result.done
+ if (done) break
+
const chunk = result.value
if (!hasStarted) {
const str = new TextDecoder().decode(chunk)
hasStarted = true
- await onStart(str)
+ try {
+ await onStart(str)
+ } catch (err) {
+ await handleCallbackError(err)
+ }
let fakeSseData
try {
@@ -33,11 +77,19 @@ export async function fetchSSE(resource, options) {
console.debug('not common response', error)
}
if (fakeSseData) {
- parser.feed(new TextEncoder().encode(fakeSseData))
+ try {
+ parser.feed(new TextEncoder().encode(fakeSseData))
+ } catch (err) {
+ await handleCallbackError(err)
+ }
break
}
}
- parser.feed(chunk)
+ try {
+ parser.feed(chunk)
+ } catch (err) {
+ await handleCallbackError(err)
+ }
}
await onEnd()
}
diff --git a/tests/unit/background/proxy-generation-state.test.mjs b/tests/unit/background/proxy-generation-state.test.mjs
new file mode 100644
index 000000000..b68aa3586
--- /dev/null
+++ b/tests/unit/background/proxy-generation-state.test.mjs
@@ -0,0 +1,187 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import {
+ clearProxyReconnectErrorSuppression,
+ consumeProxyReconnectErrorSuppression,
+ forwardProxyMessage,
+ interruptProxyGeneration,
+ isCurrentProxyGenerationMessage,
+ markProxyGenerationFinished,
+ markProxyGenerationFinishedFromMessage,
+ markProxyGenerationStarted,
+ shouldSkipProxyReconnect,
+ tagProxyRequestGeneration,
+} from '../../../src/background/proxy-generation-state.mjs'
+
+test('only the current proxy generation can update generation state', () => {
+ const port = {
+ proxy: {
+ postMessage() {},
+ },
+ }
+ markProxyGenerationStarted(port, 2, 5)
+
+ assert.equal(isCurrentProxyGenerationMessage(port, { done: true, proxyGenerationId: 2 }), true)
+ assert.deepEqual(tagProxyRequestGeneration(port, { done: true }), {
+ done: true,
+ requestGenerationId: 5,
+ })
+ assert.equal(isCurrentProxyGenerationMessage(port, { done: true, proxyGenerationId: 1 }), false)
+ assert.equal(
+ markProxyGenerationFinishedFromMessage(port, { done: true, proxyGenerationId: 1 }),
+ false,
+ )
+ assert.equal(port._generating, true)
+ assert.equal(isCurrentProxyGenerationMessage(port, { done: true }), false)
+ assert.equal(isCurrentProxyGenerationMessage(port, { done: true, stoppedGenerationId: 1 }), true)
+
+ forwardProxyMessage(port, { stop: true })
+
+ assert.equal(isCurrentProxyGenerationMessage(port, { done: true, proxyGenerationId: 2 }), false)
+})
+
+test('interrupted generation suppresses only its next reconnect error', () => {
+ const port = {}
+
+ markProxyGenerationStarted(port)
+
+ assert.equal(interruptProxyGeneration(port), true)
+ assert.equal(port._generating, false)
+ assert.equal(interruptProxyGeneration(port), false)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), true)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), false)
+})
+
+test('retry stop messages do not finish the replacement generation', () => {
+ const port = {}
+
+ for (const message of [
+ { done: true, stoppedGenerationId: 3 },
+ { error: 'stopped', stoppedGenerationId: 3 },
+ ]) {
+ markProxyGenerationStarted(port)
+
+ assert.equal(markProxyGenerationFinishedFromMessage(port, message), false)
+ assert.equal(port._generating, true)
+ }
+})
+
+test('retry stop finishes the old generation before its acknowledgement arrives', () => {
+ const postedMessages = []
+ const port = {
+ proxy: {
+ postMessage(message) {
+ postedMessages.push(message)
+ },
+ },
+ }
+
+ markProxyGenerationStarted(port)
+ forwardProxyMessage(port, { stop: true, stopGenerationId: 3 })
+
+ assert.deepEqual(postedMessages, [{ stop: true, stopGenerationId: 3 }])
+ assert.equal(port._generating, false)
+ assert.equal(
+ markProxyGenerationFinishedFromMessage(port, {
+ done: true,
+ stoppedGenerationId: 3,
+ }),
+ false,
+ )
+ assert.equal(port._generating, false)
+})
+
+test('failed retry stop forwarding still interrupts the old generation', () => {
+ const port = {
+ proxy: {
+ postMessage() {
+ throw new Error('disconnected')
+ },
+ },
+ }
+
+ markProxyGenerationStarted(port)
+
+ assert.throws(
+ () => forwardProxyMessage(port, { stop: true, stopGenerationId: 3 }),
+ /disconnected/,
+ )
+ assert.equal(port._generating, false)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), true)
+})
+
+test('forwardProxyMessage preserves an upstream stop acknowledgement across the proxy', () => {
+ const postedMessages = []
+ const port = {
+ _stopAcknowledged: true,
+ proxy: {
+ postMessage(message) {
+ postedMessages.push(message)
+ },
+ },
+ }
+
+ markProxyGenerationStarted(port)
+ forwardProxyMessage(port, { stop: true })
+
+ assert.deepEqual(postedMessages, [{ stop: true, stopAcknowledged: true }])
+ assert.equal(port._generating, false)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), true)
+})
+
+test('forwarded stop preserves reconnect error suppression from an interrupted generation', () => {
+ const port = {
+ proxy: {
+ postMessage() {},
+ },
+ }
+
+ markProxyGenerationStarted(port)
+ interruptProxyGeneration(port)
+ forwardProxyMessage(port, { stop: true })
+
+ assert.equal(port._generating, false)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), true)
+})
+
+test('normal completion and errors finish the active proxy generation', () => {
+ for (const message of [{ done: true }, { error: 'failed' }]) {
+ const port = {}
+ markProxyGenerationStarted(port)
+
+ assert.equal(markProxyGenerationFinishedFromMessage(port, message), true)
+ assert.equal(port._generating, false)
+ }
+})
+
+test('new generation clears reconnect error suppression from an interrupted generation', () => {
+ const port = {}
+
+ markProxyGenerationStarted(port)
+ interruptProxyGeneration(port)
+ markProxyGenerationStarted(port)
+
+ assert.equal(port._generating, true)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), false)
+})
+
+test('stable reconnect and normal completion clear reconnect error suppression', () => {
+ const port = {}
+
+ markProxyGenerationStarted(port)
+ interruptProxyGeneration(port)
+ clearProxyReconnectErrorSuppression(port)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), false)
+
+ markProxyGenerationStarted(port)
+ interruptProxyGeneration(port)
+ markProxyGenerationFinished(port)
+ assert.equal(port._generating, false)
+ assert.equal(consumeProxyReconnectErrorSuppression(port), false)
+})
+
+test('active or closed ports skip stale reconnect callbacks', () => {
+ assert.equal(shouldSkipProxyReconnect({}), false)
+ assert.equal(shouldSkipProxyReconnect({ proxy: {} }), true)
+ assert.equal(shouldSkipProxyReconnect({ _isClosed: true }), true)
+})
diff --git a/tests/unit/components/conversation-card-session.test.mjs b/tests/unit/components/conversation-card-session.test.mjs
new file mode 100644
index 000000000..fdf543c72
--- /dev/null
+++ b/tests/unit/components/conversation-card-session.test.mjs
@@ -0,0 +1,209 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import {
+ createConversationPortMessage,
+ createRetrySession,
+ finalizeInterruptedSession,
+ getCompletedAnswerUpdate,
+ getInterruptedCompletionState,
+ isSupersededGenerationMessage,
+ isSupersededRequestMessage,
+} from '../../../src/components/ConversationCard/session.mjs'
+
+test('finalizeInterruptedSession appends a partial answer without mutating the source session', () => {
+ const session = {
+ question: 'Q1',
+ isRetry: false,
+ conversationRecords: [],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, 'Partial answer')
+
+ assert.notEqual(updatedSession, session)
+ assert.deepEqual(session.conversationRecords, [])
+ assert.deepEqual(updatedSession.conversationRecords, [
+ { question: 'Q1', answer: 'Partial answer' },
+ ])
+})
+
+test('finalizeInterruptedSession replaces a retry answer and clears retry state', () => {
+ const session = {
+ question: 'Q1',
+ isRetry: true,
+ conversationRecords: [{ question: 'Q1', answer: 'Old answer' }],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, 'Partial retry answer')
+
+ assert.equal(session.isRetry, true)
+ assert.deepEqual(session.conversationRecords, [{ question: 'Q1', answer: 'Old answer' }])
+ assert.equal(updatedSession.isRetry, false)
+ assert.deepEqual(updatedSession.conversationRecords, [
+ { question: 'Q1', answer: 'Partial retry answer' },
+ ])
+})
+
+test('finalizeInterruptedSession appends after existing records when not retrying', () => {
+ const session = {
+ question: 'Q2',
+ isRetry: false,
+ conversationRecords: [{ question: 'Q1', answer: 'A1' }],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, 'Partial answer')
+
+ assert.deepEqual(session.conversationRecords, [{ question: 'Q1', answer: 'A1' }])
+ assert.deepEqual(updatedSession.conversationRecords, [
+ { question: 'Q1', answer: 'A1' },
+ { question: 'Q2', answer: 'Partial answer' },
+ ])
+})
+
+test('finalizeInterruptedSession restores a retry answer when interrupted before tokens arrive', () => {
+ const session = {
+ question: 'Q1',
+ isRetry: true,
+ conversationRecords: [],
+ }
+ const retryRecord = { question: 'Q1', answer: 'Old answer' }
+
+ const updatedSession = finalizeInterruptedSession(session, '', retryRecord)
+
+ assert.equal(session.isRetry, true)
+ assert.deepEqual(session.conversationRecords, [])
+ assert.equal(updatedSession.isRetry, false)
+ assert.deepEqual(updatedSession.conversationRecords, [{ question: 'Q1', answer: 'Old answer' }])
+})
+
+test('finalizeInterruptedSession restores a retry record even before retry state commits', () => {
+ const retryRecord = { question: 'Q1', answer: 'Old answer' }
+ const session = {
+ question: 'Q1',
+ isRetry: false,
+ conversationRecords: [],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, '', retryRecord)
+
+ assert.equal(updatedSession.isRetry, false)
+ assert.deepEqual(updatedSession.conversationRecords, [retryRecord])
+})
+
+test('finalizeInterruptedSession clears retry state when no record was stashed', () => {
+ const session = {
+ question: 'Q1',
+ isRetry: true,
+ conversationRecords: [],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, '')
+
+ assert.equal(updatedSession.isRetry, false)
+ assert.deepEqual(updatedSession.conversationRecords, [])
+})
+
+test('finalizeInterruptedSession does not duplicate an already restored retry record', () => {
+ const retryRecord = { question: 'Q1', answer: 'Old answer' }
+ const session = {
+ question: 'Q1',
+ isRetry: false,
+ conversationRecords: [retryRecord],
+ }
+
+ const updatedSession = finalizeInterruptedSession(session, '', retryRecord)
+
+ assert.deepEqual(updatedSession.conversationRecords, [retryRecord])
+})
+
+test('isSupersededGenerationMessage ignores only stopped generations', () => {
+ assert.equal(isSupersededGenerationMessage({ done: true }, 3), false)
+ assert.equal(isSupersededGenerationMessage({ done: true, stoppedGenerationId: 4 }, 3), false)
+ assert.equal(isSupersededGenerationMessage({ done: true, stoppedGenerationId: 3 }, 3), true)
+ assert.equal(isSupersededGenerationMessage({ session: {}, stoppedGenerationId: 2 }, 3), true)
+})
+
+test('isSupersededRequestMessage ignores responses from older requests', () => {
+ assert.equal(isSupersededRequestMessage({ done: true, requestGenerationId: 2 }, 3), true)
+ assert.equal(isSupersededRequestMessage({ done: true, requestGenerationId: 3 }, 3), false)
+ assert.equal(isSupersededRequestMessage({ done: true }, 3), false)
+})
+
+test('createConversationPortMessage preserves retry stop acknowledgement identity', () => {
+ assert.deepEqual(createConversationPortMessage({ stop: true, stopGenerationId: 3 }), {
+ session: undefined,
+ stop: true,
+ stopGenerationId: 3,
+ })
+})
+
+test('createConversationPortMessage keeps the existing payload for normal messages', () => {
+ const session = { question: 'Q1' }
+
+ assert.deepEqual(createConversationPortMessage({ session, requestGenerationId: 4 }), {
+ session,
+ stop: undefined,
+ requestGenerationId: 4,
+ })
+})
+
+test('createRetrySession appends after a removed retry target', () => {
+ const conversationRecords = [{ question: 'Q1', answer: 'A1' }]
+
+ const retrySession = createRetrySession(
+ { question: 'Q1', isRetry: true, conversationRecords: [] },
+ conversationRecords,
+ { question: 'Q1', answer: 'Old answer' },
+ )
+
+ assert.equal(retrySession.isRetry, false)
+ assert.equal(retrySession.conversationRecords, conversationRecords)
+})
+
+test('createRetrySession keeps provider retry mode when no target was removed', () => {
+ const retrySession = createRetrySession(
+ { question: 'Q1', isRetry: false, conversationRecords: [] },
+ [],
+ null,
+ )
+
+ assert.equal(retrySession.isRetry, true)
+})
+
+test('getCompletedAnswerUpdate replaces loading content when restoring a retry answer', () => {
+ assert.deepEqual(getCompletedAnswerUpdate('Old answer'), {
+ value: 'Old answer',
+ appended: false,
+ })
+})
+
+test('getCompletedAnswerUpdate preserves streamed content on normal completion', () => {
+ assert.deepEqual(getCompletedAnswerUpdate(null), {
+ value: '',
+ appended: true,
+ })
+})
+
+test('getInterruptedCompletionState restores retry context for a sessionless stop', () => {
+ const retryRecord = { question: 'Q1', answer: 'Old answer' }
+
+ assert.deepEqual(getInterruptedCompletionState({ done: true }, '', retryRecord), {
+ shouldFinalize: true,
+ restoredRetryAnswer: 'Old answer',
+ })
+})
+
+test('getInterruptedCompletionState finalizes on proxy disconnect with a session', () => {
+ const retryRecord = { question: 'Q1', answer: 'Old answer' }
+
+ assert.deepEqual(
+ getInterruptedCompletionState(
+ { done: true, proxyDisconnected: true, session: {} },
+ '',
+ retryRecord,
+ ),
+ {
+ shouldFinalize: true,
+ restoredRetryAnswer: 'Old answer',
+ },
+ )
+})
diff --git a/tests/unit/services/apis/bard-web.test.mjs b/tests/unit/services/apis/bard-web.test.mjs
new file mode 100644
index 000000000..ef7a4366b
--- /dev/null
+++ b/tests/unit/services/apis/bard-web.test.mjs
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { generateAnswersWithBardWebApi } from '../../../../src/services/apis/bard-web.mjs'
+import Bard from '../../../../src/services/clients/bard/index.mjs'
+import { createFakePort } from '../../helpers/port.mjs'
+
+test('generateAnswersWithBardWebApi ignores a superseded response', async (t) => {
+ let resolveResponse
+ const response = new Promise((resolve) => {
+ resolveResponse = resolve
+ })
+ t.mock.method(Bard.prototype, 'ask', () => response)
+
+ const session = { conversationRecords: [] }
+ const port = createFakePort()
+ let isLatest = true
+ const generation = generateAnswersWithBardWebApi(
+ port,
+ 'CurrentQ',
+ session,
+ 'cookie',
+ () => isLatest,
+ )
+
+ isLatest = false
+ resolveResponse({ answer: 'Stale answer', conversationObj: { id: 'stale' } })
+ await generation
+
+ assert.deepEqual(session, { conversationRecords: [] })
+ assert.deepEqual(port.postedMessages, [])
+})
diff --git a/tests/unit/services/apis/moonshot-web.test.mjs b/tests/unit/services/apis/moonshot-web.test.mjs
new file mode 100644
index 000000000..4e9b47008
--- /dev/null
+++ b/tests/unit/services/apis/moonshot-web.test.mjs
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { MoonshotWeb } from '../../../../src/services/apis/moonshot-web.mjs'
+
+test('MoonshotWeb.init forwards its signal to user and refresh requests', async () => {
+ const controller = new AbortController()
+ const requests = []
+ const config = {
+ kimiMoonShotAccessToken: 'old-access-token',
+ kimiMoonShotRefreshToken: 'old-refresh-token',
+ }
+ const bot = new MoonshotWeb({
+ config,
+ fetch: async (endpoint, options) => {
+ requests.push({ endpoint, options })
+ if (endpoint.endsWith('/api/user')) return { status: 401 }
+ return {
+ json: async () => ({
+ access_token: 'new-access-token',
+ refresh_token: 'new-refresh-token',
+ }),
+ }
+ },
+ })
+
+ await bot.init(controller.signal)
+
+ assert.deepEqual(
+ requests.map(({ endpoint }) => endpoint),
+ ['https://www.kimi.com/api/user', 'https://www.kimi.com/api/auth/token/refresh'],
+ )
+ assert.equal(
+ requests.every(({ options }) => options.signal === controller.signal),
+ true,
+ )
+})
diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs
index d4c6494f9..f4a7f96ca 100644
--- a/tests/unit/services/apis/openai-api-compat.test.mjs
+++ b/tests/unit/services/apis/openai-api-compat.test.mjs
@@ -6,6 +6,7 @@ import {
generateAnswersWithGptCompletionApi,
generateAnswersWithOpenAICompatibleApi,
} from '../../../../src/services/apis/openai-api.mjs'
+import { claimLatestPortSessionRequest } from '../../../../src/services/wrappers.mjs'
import { createFakePort } from '../../helpers/port.mjs'
import { createMockSseResponse } from '../../helpers/sse-response.mjs'
@@ -28,6 +29,36 @@ const setStorage = (values) => {
globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values)
}
+const createStoppedSseResponse = (port, afterStop) => {
+ const encoder = new TextEncoder()
+ let readCount = 0
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: {
+ getReader() {
+ return {
+ async read() {
+ readCount += 1
+ if (readCount === 1) {
+ return {
+ done: false,
+ value: encoder.encode('data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n'),
+ }
+ }
+ port.emitMessage({ stop: true })
+ afterStop?.()
+ throw Object.assign(new Error('The operation was aborted'), {
+ name: 'AbortError',
+ })
+ },
+ }
+ },
+ },
+ }
+}
+
beforeEach(() => {
globalThis.__TEST_BROWSER_SHIM__.clearStorage()
})
@@ -138,6 +169,188 @@ test('generateAnswersWithOpenAiApiCompat emits fallback done message when stream
})
})
+test('generateAnswersWithOpenAiApiCompat preserves partial retry answer when aborted port is closed', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ t.mock.method(console, 'warn', () => {})
+ setStorage({
+ maxConversationContextLength: 3,
+ maxResponseTokenLength: 256,
+ temperature: 0.25,
+ })
+
+ const session = {
+ modelName: 'chatgptApi4oMini',
+ conversationRecords: [{ question: 'CurrentQ', answer: 'Old answer' }],
+ isRetry: true,
+ }
+ const port = createFakePort()
+ const originalPostMessage = port.postMessage.bind(port)
+ port.postMessage = (message) => {
+ if (message?.session && !message.done) {
+ throw new Error('Port closed')
+ }
+ originalPostMessage(message)
+ }
+
+ t.mock.method(globalThis, 'fetch', async () => {
+ const encoder = new TextEncoder()
+ let readCount = 0
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: {
+ getReader() {
+ return {
+ async read() {
+ readCount += 1
+ if (readCount === 1) {
+ return {
+ done: false,
+ value: encoder.encode('data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n'),
+ }
+ }
+ throw Object.assign(new Error('The operation was aborted'), {
+ name: 'AbortError',
+ })
+ },
+ }
+ },
+ },
+ }
+ })
+
+ await generateAnswersWithOpenAiApiCompat(
+ 'https://api.example.com/v1',
+ port,
+ 'CurrentQ',
+ session,
+ 'sk-test',
+ )
+
+ assert.equal(
+ port.postedMessages.some((message) => message.done === false && message.answer === 'Partial'),
+ true,
+ )
+ assert.deepEqual(session.conversationRecords, [{ question: 'CurrentQ', answer: 'Partial' }])
+ assert.equal(session.isRetry, false)
+ assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 })
+})
+
+test('generateAnswersWithOpenAiApiCompat clears retry state when aborted before first chunk', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({
+ maxConversationContextLength: 3,
+ maxResponseTokenLength: 256,
+ temperature: 0.25,
+ })
+
+ const session = {
+ modelName: 'chatgptApi4oMini',
+ conversationRecords: [{ question: 'CurrentQ', answer: 'Old answer' }],
+ isRetry: true,
+ }
+ const port = createFakePort()
+
+ t.mock.method(globalThis, 'fetch', async () => ({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: {
+ getReader() {
+ return {
+ async read() {
+ port.emitMessage({ stop: true, stopGenerationId: 7 })
+ throw Object.assign(new Error('The operation was aborted'), {
+ name: 'AbortError',
+ })
+ },
+ }
+ },
+ },
+ }))
+
+ await generateAnswersWithOpenAiApiCompat(
+ 'https://api.example.com/v1',
+ port,
+ 'CurrentQ',
+ session,
+ 'sk-test',
+ )
+
+ assert.deepEqual(session.conversationRecords, [{ question: 'CurrentQ', answer: 'Old answer' }])
+ assert.equal(session.isRetry, false)
+ assert.deepEqual(port.postedMessages.at(-1), { session, stoppedGenerationId: 7 })
+ assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 })
+})
+
+test('generateAnswersWithOpenAiApiCompat ignores an aborted session after a newer request starts', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({
+ maxConversationContextLength: 3,
+ maxResponseTokenLength: 256,
+ temperature: 0.25,
+ })
+
+ const session = {
+ modelName: 'chatgptApi4oMini',
+ conversationRecords: [],
+ isRetry: false,
+ }
+ const port = createFakePort()
+ claimLatestPortSessionRequest(port)
+
+ t.mock.method(globalThis, 'fetch', async () =>
+ createStoppedSseResponse(port, () => claimLatestPortSessionRequest(port)),
+ )
+
+ await generateAnswersWithOpenAiApiCompat(
+ 'https://api.example.com/v1',
+ port,
+ 'CurrentQ',
+ session,
+ 'sk-test',
+ )
+
+ assert.deepEqual(session.conversationRecords, [])
+ assert.equal(
+ port.postedMessages.some((message) => message.session),
+ false,
+ )
+ assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 })
+})
+
+test('generateAnswersWithOpenAiApiCompat preserves an aborted session without a newer request', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({
+ maxConversationContextLength: 3,
+ maxResponseTokenLength: 256,
+ temperature: 0.25,
+ })
+
+ const session = {
+ modelName: 'chatgptApi4oMini',
+ conversationRecords: [],
+ isRetry: false,
+ }
+ const port = createFakePort()
+ claimLatestPortSessionRequest(port)
+
+ t.mock.method(globalThis, 'fetch', async () => createStoppedSseResponse(port))
+
+ await generateAnswersWithOpenAiApiCompat(
+ 'https://api.example.com/v1',
+ port,
+ 'CurrentQ',
+ session,
+ 'sk-test',
+ )
+
+ assert.deepEqual(session.conversationRecords, [{ question: 'CurrentQ', answer: 'Partial' }])
+ assert.deepEqual(port.postedMessages.at(-1), { session })
+ assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 })
+})
+
test('generateAnswersWithOpenAiApiCompat records an empty answer when stream ends before first chunk', async (t) => {
t.mock.method(console, 'debug', () => {})
setStorage({
diff --git a/tests/unit/services/apis/shared.test.mjs b/tests/unit/services/apis/shared.test.mjs
index f06fa8ec1..981ec61e4 100644
--- a/tests/unit/services/apis/shared.test.mjs
+++ b/tests/unit/services/apis/shared.test.mjs
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
-import { pushRecord, setAbortController } from '../../../../src/services/apis/shared.mjs'
+import {
+ acknowledgePortStop,
+ pushRecord,
+ setAbortController,
+} from '../../../../src/services/apis/shared.mjs'
import { createFakePort } from '../../helpers/port.mjs'
test('pushRecord appends a new record in normal mode', () => {
@@ -58,6 +62,34 @@ test('setAbortController aborts and cleans listeners on stop message', (t) => {
assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 1 })
})
+test('setAbortController echoes a retry stop generation id', (t) => {
+ t.mock.method(console, 'debug', () => {})
+ const port = createFakePort()
+ const { getStopGenerationId } = setAbortController(port)
+
+ port.emitMessage({ stop: true, stopGenerationId: 7 })
+
+ assert.deepEqual(port.postedMessages, [{ done: true, stoppedGenerationId: 7 }])
+ assert.equal(getStopGenerationId(), 7)
+})
+
+test('acknowledgePortStop posts only once for the current session request', () => {
+ const port = createFakePort()
+ const message = { stop: true, stopGenerationId: 7 }
+
+ assert.equal(acknowledgePortStop(port, message), true)
+ assert.equal(acknowledgePortStop(port, message), false)
+ assert.equal(message.stopAcknowledged, true)
+ assert.deepEqual(port.postedMessages, [{ done: true, stoppedGenerationId: 7 }])
+})
+
+test('acknowledgePortStop respects an acknowledgement from an upstream port', () => {
+ const port = createFakePort()
+
+ assert.equal(acknowledgePortStop(port, { stop: true, stopAcknowledged: true }), false)
+ assert.deepEqual(port.postedMessages, [])
+})
+
test('setAbortController aborts on disconnect and removes disconnect listener', (t) => {
t.mock.method(console, 'debug', () => {})
const port = createFakePort()
diff --git a/tests/unit/services/handle-port-error.test.mjs b/tests/unit/services/handle-port-error.test.mjs
index 0962e0026..ec3b63226 100644
--- a/tests/unit/services/handle-port-error.test.mjs
+++ b/tests/unit/services/handle-port-error.test.mjs
@@ -1,9 +1,33 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { t as translate } from 'i18next'
-import { handlePortError } from '../../../src/services/wrappers.mjs'
+import {
+ claimLatestPortSessionRequest,
+ handlePortError,
+ invalidateLatestPortSessionRequest,
+} from '../../../src/services/wrappers.mjs'
import { createFakePort } from '../helpers/port.mjs'
+test('claimLatestPortSessionRequest supersedes pending requests on the same port', () => {
+ const port = {}
+ const isFirstRequestLatest = claimLatestPortSessionRequest(port)
+ const isSecondRequestLatest = claimLatestPortSessionRequest(port)
+
+ assert.equal(isFirstRequestLatest(), false)
+ assert.equal(isSecondRequestLatest(), true)
+ assert.equal(port._sessionRequestGeneration, 2)
+})
+
+test('invalidateLatestPortSessionRequest cancels a pending request', () => {
+ const port = {}
+ const isRequestLatest = claimLatestPortSessionRequest(port)
+
+ invalidateLatestPortSessionRequest(port)
+
+ assert.equal(isRequestLatest(), false)
+ assert.equal(port._sessionRequestGeneration, 1)
+})
+
test('handlePortError reports exceeded maximum context length', (t) => {
t.mock.method(console, 'error', () => {})
const port = createFakePort()
@@ -112,6 +136,37 @@ test('handlePortError ignores AbortError by name even when message text differs'
assert.equal(consoleError.mock.callCount(), 0)
})
+test('handlePortError ignores disconnected port errors', (t) => {
+ const consoleError = t.mock.method(console, 'error', () => {})
+ const consoleWarn = t.mock.method(console, 'warn', () => {})
+
+ for (const message of [
+ 'Attempting to use a disconnected port object',
+ 'Attempt to postMessage on disconnected port',
+ ]) {
+ const port = createFakePort()
+
+ handlePortError({ modelName: 'chatgptApi4oMini' }, port, { message })
+
+ assert.deepEqual(port.postedMessages, [])
+ }
+ assert.equal(consoleError.mock.callCount(), 0)
+ assert.equal(consoleWarn.mock.callCount(), 2)
+})
+
+test('handlePortError reports upstream errors that mention a closed port', (t) => {
+ t.mock.method(console, 'error', () => {})
+ const port = createFakePort()
+
+ handlePortError({ modelName: 'chatgptApi4oMini' }, port, {
+ message: 'Upstream reset the request because its port closed',
+ })
+
+ assert.deepEqual(port.postedMessages, [
+ { error: 'Upstream reset the request because its port closed' },
+ ])
+})
+
test('handlePortError reports Claude web authorization hint', (t) => {
t.mock.method(console, 'error', () => {})
const port = createFakePort()
@@ -197,3 +252,20 @@ test('handlePortError handles undefined thrown values without throwing again', (
assert.equal(port.postedMessages.length, 1)
assert.equal(port.postedMessages[0].error, 'unknown error')
})
+
+test('handlePortError does not throw when the error port is closed', (t) => {
+ t.mock.method(console, 'error', () => {})
+ const consoleWarn = t.mock.method(console, 'warn', () => {})
+ const port = {
+ postMessage() {
+ throw new Error('Port closed')
+ },
+ }
+
+ assert.doesNotThrow(() => {
+ handlePortError({ modelName: 'chatgptApi4oMini' }, port, {
+ message: 'done failed',
+ })
+ })
+ assert.equal(consoleWarn.mock.callCount(), 1)
+})
diff --git a/tests/unit/services/wrappers-register.test.mjs b/tests/unit/services/wrappers-register.test.mjs
index 2b5086722..e04c24aca 100644
--- a/tests/unit/services/wrappers-register.test.mjs
+++ b/tests/unit/services/wrappers-register.test.mjs
@@ -222,6 +222,116 @@ test('registerPortListener ignores messages without session', async (t) => {
assert.deepEqual(port.postedMessages, [])
})
+test('registerPortListener tags responses with proxy and request generation ids', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({ modelName: 'chatgptApi4oMini' })
+
+ let resolveExec
+ const execDone = new Promise((resolve) => {
+ resolveExec = resolve
+ })
+ const executor = t.mock.fn(async (_session, requestPort) => {
+ requestPort.postMessage({ done: true })
+ resolveExec()
+ })
+
+ registerPortListener(executor)
+ const port = createFakePort()
+ triggerConnect(port)
+
+ port.emitMessage({
+ session: { conversationRecords: [] },
+ proxyGenerationId: 7,
+ requestGenerationId: 11,
+ })
+ await execDone
+
+ assert.equal(port.postedMessages.length, 2)
+ assert.equal(
+ port.postedMessages.every(
+ (message) => message.proxyGenerationId === 7 && message.requestGenerationId === 11,
+ ),
+ true,
+ )
+})
+
+test('registerPortListener drops responses from a superseded session request', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({ modelName: 'chatgptApi4oMini' })
+
+ const requestPorts = []
+ const connectionPorts = []
+ let resolveRequest
+ const requestReady = () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve
+ })
+ let ready = requestReady()
+ const executor = t.mock.fn(
+ async (
+ _session,
+ requestPort,
+ _config,
+ _isLatestSessionRequest,
+ _requestGenerationId,
+ connectionPort,
+ ) => {
+ requestPorts.push(requestPort)
+ connectionPorts.push(connectionPort)
+ resolveRequest()
+ },
+ )
+
+ registerPortListener(executor)
+ const port = createFakePort()
+ triggerConnect(port)
+
+ port.emitMessage({ session: { conversationRecords: [] } })
+ await ready
+ ready = requestReady()
+ port.emitMessage({ session: { conversationRecords: [] } })
+ await ready
+
+ assert.notEqual(requestPorts[0], requestPorts[1])
+ assert.deepEqual(connectionPorts, [port, port])
+
+ requestPorts[0].postMessage({ done: true })
+ assert.equal(port.postedMessages.length, 2)
+ requestPorts[1].postMessage({ done: true })
+
+ assert.equal(port.postedMessages.length, 3)
+ assert.deepEqual(port.postedMessages.at(-1), { done: true })
+})
+
+test('registerPortListener allows a stopped request to post before its replacement starts', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ setStorage({ modelName: 'chatgptApi4oMini' })
+
+ let requestPort
+ let resolveExec
+ const execReady = new Promise((resolve) => {
+ resolveExec = resolve
+ })
+ const executor = t.mock.fn(async (_session, currentRequestPort) => {
+ requestPort = currentRequestPort
+ resolveExec()
+ })
+
+ registerPortListener(executor)
+ const port = createFakePort()
+ triggerConnect(port)
+
+ port.emitMessage({ session: { conversationRecords: [] } })
+ await execReady
+ port.emitMessage({ stop: true })
+ requestPort.postMessage({ session: { conversationRecords: [] } })
+
+ assert.deepEqual(port.postedMessages.slice(-2), [
+ { done: true },
+ { session: { conversationRecords: [] } },
+ ])
+})
+
test('registerPortListener catches executor errors and calls handlePortError', async (t) => {
t.mock.method(console, 'debug', () => {})
t.mock.method(console, 'error', () => {})
diff --git a/tests/unit/utils/fetch-sse.test.mjs b/tests/unit/utils/fetch-sse.test.mjs
index dc6254c4f..3f5425086 100644
--- a/tests/unit/utils/fetch-sse.test.mjs
+++ b/tests/unit/utils/fetch-sse.test.mjs
@@ -112,3 +112,93 @@ test('fetchSSE forwards fetch rejection errors to onError', async (t) => {
assert.equal(errors.length, 1)
assert.equal(errors[0].message, 'network down')
})
+
+test('fetchSSE treats an AbortError before streaming as cancelled completion', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ const consoleWarn = t.mock.method(console, 'warn', () => {})
+ const errors = []
+ let aborted = false
+
+ t.mock.method(globalThis, 'fetch', async () => {
+ throw Object.assign(new Error('The operation was aborted'), {
+ name: 'AbortError',
+ })
+ })
+
+ await fetchSSE('https://example.com/abort', {
+ onStart: async () => {},
+ onMessage: () => {},
+ onEnd: async (wasAborted) => {
+ aborted = wasAborted
+ throw new Error('cleanup failed')
+ },
+ onError: async (error) => {
+ errors.push(error)
+ },
+ })
+
+ assert.equal(aborted, true)
+ assert.deepEqual(errors, [])
+ assert.equal(consoleWarn.mock.callCount(), 1)
+})
+
+test('fetchSSE propagates onEnd errors on normal completion', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n']))
+
+ await assert.rejects(
+ fetchSSE('https://example.com/sse', {
+ onStart: async () => {},
+ onMessage: () => {},
+ onEnd: async () => {
+ throw new Error('done failed')
+ },
+ onError: async () => {},
+ }),
+ /done failed/,
+ )
+})
+
+test('fetchSSE propagates onStart errors', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n']))
+ const errors = []
+
+ await assert.rejects(
+ fetchSSE('https://example.com/sse', {
+ onStart: async () => {
+ throw new Error('start failed')
+ },
+ onMessage: () => {},
+ onEnd: async () => {},
+ onError: async (error) => {
+ errors.push(error)
+ },
+ }),
+ /start failed/,
+ )
+ assert.equal(errors.length, 1)
+ assert.equal(errors[0].message, 'start failed')
+})
+
+test('fetchSSE propagates onMessage errors', async (t) => {
+ t.mock.method(console, 'debug', () => {})
+ t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n']))
+ const errors = []
+
+ await assert.rejects(
+ fetchSSE('https://example.com/sse', {
+ onStart: async () => {},
+ onMessage: () => {
+ throw new Error('message failed')
+ },
+ onEnd: async () => {},
+ onError: async (error) => {
+ errors.push(error)
+ },
+ }),
+ /message failed/,
+ )
+ assert.equal(errors.length, 1)
+ assert.equal(errors[0].message, 'message failed')
+})