From 4421eee8fbab1155e4ed32830ee017939200e2d4 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Thu, 9 Jul 2026 03:45:38 +0800 Subject: [PATCH] Fix UI stuck on Stop when request is slow or proxy disconnects - background: unlock the conversation UI (post {done:true}) when the ChatGPT/Claude/etc. proxy tab disconnects mid-generation; use a per-port _generating flag so only in-flight requests are affected. - fetch-sse: treat abort as onEnd(true) and swallow onEnd errors so a closed port on disconnect is not reported as a failure. - openai-compatible-core: on abort, persist the streamed partial answer into session history (pushRecord + post {session}) without re-sending the done signal. - moonshot-web/claude client: on abort, reject the stream promise so cancellation is treated as cancellation, not a successful completion. --- src/background/index.mjs | 159 ++++++++++--- src/background/proxy-generation-state.mjs | 64 ++++++ src/components/ConversationCard/index.jsx | 117 ++++++++-- src/components/ConversationCard/session.mjs | 79 +++++++ src/content-script/index.jsx | 10 +- src/services/apis/azure-openai-api.mjs | 13 +- src/services/apis/bard-web.mjs | 12 +- src/services/apis/chatgpt-web.mjs | 141 ++++++++---- src/services/apis/claude-api.mjs | 13 +- src/services/apis/claude-web.mjs | 14 +- src/services/apis/moonshot-web.mjs | 27 ++- src/services/apis/openai-compatible-core.mjs | 40 +++- src/services/apis/shared.mjs | 31 ++- src/services/apis/waylaidwanderer-api.mjs | 13 +- src/services/clients/claude/index.mjs | 6 +- src/services/wrappers.mjs | 101 +++++++-- src/utils/fetch-sse.mjs | 66 +++++- .../proxy-generation-state.test.mjs | 187 +++++++++++++++ .../conversation-card-session.test.mjs | 209 +++++++++++++++++ tests/unit/services/apis/bard-web.test.mjs | 31 +++ .../unit/services/apis/moonshot-web.test.mjs | 36 +++ .../services/apis/openai-api-compat.test.mjs | 213 ++++++++++++++++++ tests/unit/services/apis/shared.test.mjs | 34 ++- .../unit/services/handle-port-error.test.mjs | 74 +++++- .../unit/services/wrappers-register.test.mjs | 110 +++++++++ tests/unit/utils/fetch-sse.test.mjs | 90 ++++++++ 26 files changed, 1735 insertions(+), 155 deletions(-) create mode 100644 src/background/proxy-generation-state.mjs create mode 100644 src/components/ConversationCard/session.mjs create mode 100644 tests/unit/background/proxy-generation-state.test.mjs create mode 100644 tests/unit/components/conversation-card-session.test.mjs create mode 100644 tests/unit/services/apis/bard-web.test.mjs create mode 100644 tests/unit/services/apis/moonshot-web.test.mjs 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') +})