From 46786e093745433b1b877b427c4e86af321425ad Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 27 Jul 2026 23:46:31 +0800 Subject: [PATCH] Improve Claude stream completion handling Reject token, context, refusal, and nonterminal stop conditions instead of treating incomplete Claude responses as successful completions. Stop processing once a response completes and surface streamed API errors without undoing completed responses. Add localized completion errors and regression coverage for stop reasons, provider errors, transport failures, callbacks, and aborts. Follow-up to #1026. --- src/_locales/de/main.json | 4 + src/_locales/en/main.json | 4 + src/_locales/es/main.json | 4 + src/_locales/fr/main.json | 4 + src/_locales/in/main.json | 4 + src/_locales/it/main.json | 4 + src/_locales/ja/main.json | 4 + src/_locales/ko/main.json | 4 + src/_locales/pt/main.json | 4 + src/_locales/ru/main.json | 4 + src/_locales/tr/main.json | 4 + src/_locales/zh-hans/main.json | 4 + src/_locales/zh-hant/main.json | 4 + src/services/apis/claude-api.mjs | 59 ++- tests/unit/services/apis/claude-api.test.mjs | 387 ++++++++++++++++++- 15 files changed, 484 insertions(+), 14 deletions(-) diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index e517723df..350a5a10d 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -87,6 +87,10 @@ "Clear Conversation": "Konversation löschen", "Retry": "Erneut versuchen", "Exceeded maximum context length": "Maximale Kontextlänge überschritten, bitte Konversation löschen und erneut versuchen", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude hat das Antwort-Token-Limit erreicht. Erhöhen Sie die maximale Antwort-Token-Länge und versuchen Sie es erneut.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude hat das Kontextfenster des Modells ausgeschöpft. Löschen Sie die Konversation und versuchen Sie es erneut.", + "Claude declined to respond to this request.": "Claude hat die Antwort auf diese Anfrage abgelehnt.", + "Claude response stream ended before completion.": "Der Claude-Antwortstream wurde vorzeitig beendet.", "Regenerate the answer after switching model": "Antwort nach dem Wechseln des Modells neu generieren", "Pin": "Anheften", "Unpin": "Loslösen", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 5f1428218..10f8caeeb 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -94,6 +94,10 @@ "Clear Conversation": "Clear Conversation", "Retry": "Retry", "Exceeded maximum context length": "Exceeded maximum context length, please clear the conversation and try again", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude reached the response token limit. Increase Max Response Token Length and try again.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude reached the model context window limit. Clear the conversation and try again.", + "Claude declined to respond to this request.": "Claude declined to respond to this request.", + "Claude response stream ended before completion.": "Claude response stream ended before completion.", "Regenerate the answer after switching model": "Regenerate the answer after switching model", "Pin": "Pin", "Unpin": "Unpin", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index 42ca47d64..14953fb28 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Borrar conversación", "Retry": "Reintentar", "Exceeded maximum context length": "Se superó la longitud máxima del contexto, borre la conversación y vuelva a intentarlo", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude alcanzó el límite de tokens de respuesta. Aumente la longitud máxima de tokens de respuesta e inténtelo de nuevo.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude alcanzó el límite de la ventana de contexto del modelo. Borre la conversación e inténtelo de nuevo.", + "Claude declined to respond to this request.": "Claude se negó a responder a esta solicitud.", + "Claude response stream ended before completion.": "El flujo de respuesta de Claude terminó antes de completarse.", "Regenerate the answer after switching model": "Regenerar la respuesta después de cambiar el modelo", "Pin": "Fijar", "Unpin": "Desfijar", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index 3c7c88b98..3b0546ca1 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Effacer la conversation", "Retry": "Réessayer", "Exceeded maximum context length": "Dépassement de la longueur maximale de contexte, veuillez effacer la conversation et réessayer", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude a atteint la limite de jetons de réponse. Augmentez la longueur maximale des jetons de réponse, puis réessayez.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude a atteint la limite de la fenêtre de contexte du modèle. Effacez la conversation, puis réessayez.", + "Claude declined to respond to this request.": "Claude a refusé de répondre à cette requête.", + "Claude response stream ended before completion.": "Le flux de réponse de Claude s’est terminé avant la fin.", "Regenerate the answer after switching model": "Régénérer la réponse après avoir changé de modèle", "Pin": "Épingler", "Unpin": "Détacher", diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 80643835b..f38e645fc 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Bersihkan Percakapan", "Retry": "Coba Lagi", "Exceeded maximum context length": "Melampaui batas maksimum panjang konteks, harap bersihkan percakapan dan coba lagi", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude mencapai batas token respons. Tingkatkan panjang token respons maksimum lalu coba lagi.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude mencapai batas jendela konteks model. Hapus percakapan lalu coba lagi.", + "Claude declined to respond to this request.": "Claude menolak menanggapi permintaan ini.", + "Claude response stream ended before completion.": "Aliran respons Claude berakhir sebelum selesai.", "Regenerate the answer after switching model": "Hasilkan kembali jawaban setelah beralih ke model lain", "Pin": "Sematkan", "Unpin": "Lepas Sematan", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 3076e0baf..8149c7b5c 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Pulisci la conversazione", "Retry": "Riprova", "Exceeded maximum context length": "Lunghezza massima del contesto superata, si prega di pulire la conversazione e riprovare", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude ha raggiunto il limite di token della risposta. Aumenta la lunghezza massima dei token della risposta e riprova.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude ha raggiunto il limite della finestra di contesto del modello. Cancella la conversazione e riprova.", + "Claude declined to respond to this request.": "Claude ha rifiutato di rispondere a questa richiesta.", + "Claude response stream ended before completion.": "Il flusso di risposta di Claude è terminato prima del completamento.", "Regenerate the answer after switching model": "Rigenerare la risposta dopo aver cambiato il modello", "Pin": "Fissa", "Unpin": "Sblocca", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 6f98b2456..b95cf7eab 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "会話をクリア", "Retry": "再試行", "Exceeded maximum context length": "最大コンテキスト長を超えました。会話をクリアして再試行してください", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude が応答トークンの上限に達しました。最大応答トークン長を増やして、もう一度お試しください。", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude がモデルのコンテキストウィンドウ上限に達しました。会話をクリアして、もう一度お試しください。", + "Claude declined to respond to this request.": "Claude はこのリクエストへの応答を拒否しました。", + "Claude response stream ended before completion.": "Claude の応答ストリームが完了前に終了しました。", "Regenerate the answer after switching model": "モデルを切り替えた後に回答を再生成", "Pin": "ピン留め", "Unpin": "ピン留め解除", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 232500add..7ab91041d 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "대화 내용 지우기", "Retry": "재시도", "Exceeded maximum context length": "최대 컨텍스트 길이를 초과하였습니다. 대화 내용을 지우고 다시 시도해주세요.", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude가 응답 토큰 한도에 도달했습니다. 최대 응답 토큰 길이를 늘린 후 다시 시도하세요.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude가 모델의 컨텍스트 창 한도에 도달했습니다. 대화 내용을 지운 후 다시 시도하세요.", + "Claude declined to respond to this request.": "Claude가 이 요청에 대한 응답을 거부했습니다.", + "Claude response stream ended before completion.": "Claude 응답 스트림이 완료되기 전에 종료되었습니다.", "Regenerate the answer after switching model": "모델 전환 후 대답 다시 생성", "Pin": "고정", "Unpin": "고정 해제", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index bcef7962f..36045eb86 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Limpar Conversa", "Retry": "Tentar novamente", "Exceeded maximum context length": "Ultrapassou o comprimento máximo do contexto. Limpe a conversa e tente novamente", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "O Claude atingiu o limite de tokens da resposta. Aumente o comprimento máximo dos tokens da resposta e tente novamente.", + "Claude reached the model context window limit. Clear the conversation and try again.": "O Claude atingiu o limite da janela de contexto do modelo. Limpe a conversa e tente novamente.", + "Claude declined to respond to this request.": "O Claude recusou responder a este pedido.", + "Claude response stream ended before completion.": "O fluxo de resposta do Claude terminou antes de ser concluído.", "Regenerate the answer after switching model": "Regenerar a resposta após trocar o modelo", "Pin": "Fixar", "Unpin": "Desafixar", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index 702ba62f7..aedd70ce9 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -93,6 +93,10 @@ "Clear Conversation": "Очистить беседу", "Retry": "Повторить", "Exceeded maximum context length": "Превышена максимальная длина контекста, очистите беседу и повторите попытку", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude достиг лимита токенов ответа. Увеличьте максимальную длину ответа в токенах и повторите попытку.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude достиг лимита контекстного окна модели. Очистите беседу и повторите попытку.", + "Claude declined to respond to this request.": "Claude отказался отвечать на этот запрос.", + "Claude response stream ended before completion.": "Поток ответа Claude завершился до окончания ответа.", "Regenerate the answer after switching model": "Генерировать ответ после смены модели", "Pin": "Закрепить", "Unpin": "Открепить", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 30df5336e..e2a6bb556 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -85,6 +85,10 @@ "Clear Conversation": "Konuşmayı Temizle", "Retry": "Tekrar Dene", "Exceeded maximum context length": "Maksimum bağlam uzunluğu aşıldı", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude yanıt belirteci sınırına ulaştı. Maksimum yanıt belirteci uzunluğunu artırıp yeniden deneyin.", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude modelin bağlam penceresi sınırına ulaştı. Konuşmayı temizleyip yeniden deneyin.", + "Claude declined to respond to this request.": "Claude bu isteğe yanıt vermeyi reddetti.", + "Claude response stream ended before completion.": "Claude yanıt akışı tamamlanmadan sona erdi.", "Regenerate the answer after switching model": "Modeli değiştirdikten sonra cevabı yeniden oluştur", "Pin": "Sabitle", "Unpin": "Sabitlemeyi Kaldır", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index 7a69891e2..d15d0c393 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -88,6 +88,10 @@ "Clear Conversation": "清理对话", "Retry": "重试", "Exceeded maximum context length": "超出最大上下文长度, 请清理对话并重试", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude 已达到响应 token 上限。请增大最大响应 token 长度,然后重试。", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude 已达到模型上下文窗口上限。请清理对话,然后重试。", + "Claude declined to respond to this request.": "Claude 拒绝响应此请求。", + "Claude response stream ended before completion.": "Claude 响应流在完成前已结束。", "Regenerate the answer after switching model": "快捷切换模型时自动重新生成回答", "Pin": "固定侧边", "Unpin": "收缩侧边", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 833411db9..bc200d682 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -88,6 +88,10 @@ "Clear Conversation": "清除對話", "Retry": "重試", "Exceeded maximum context length": "超出最大上下文長度,請清除對話並重試", + "Claude reached the response token limit. Increase Max Response Token Length and try again.": "Claude 已達回應 token 上限。請提高 [回應的最大 token 長度],然後再試一次。", + "Claude reached the model context window limit. Clear the conversation and try again.": "Claude 已達模型的上下文視窗上限。請清除對話後再試一次。", + "Claude declined to respond to this request.": "Claude 拒絕回應這項要求。", + "Claude response stream ended before completion.": "Claude 回應串流尚未完成便已結束。", "Regenerate the answer after switching model": "切換模型後自動重新產生回答", "Pin": "固定側邊", "Unpin": "取消固定側邊", diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs index 0f15680df..c9b4f9caf 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -1,6 +1,6 @@ import { getUserConfig } from '../../config/index.mjs' import { pushRecord, setAbortController } from './shared.mjs' -import { fetchSSE } from '../../utils/fetch-sse.mjs' +import { FETCH_RESPONSE_STREAM_FAILED, fetchSSE } from '../../utils/fetch-sse.mjs' import { isEmpty } from 'lodash-es' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' @@ -49,7 +49,11 @@ export async function generateAnswersWithClaudeApi(port, question, session) { } let answer = '' - await fetchSSE(`${apiUrl}/v1/messages`, { + let stopReason = '' + let completionError + let wasAborted = false + let completedSuccessfully = false + const streamError = await fetchSSE(`${apiUrl}/v1/messages`, { method: 'POST', signal: controller.signal, headers: { @@ -69,10 +73,42 @@ export async function generateAnswersWithClaudeApi(port, question, session) { console.debug('json error', error) return } + if (data?.type === 'error') { + controller.abort() + const error = new Error(JSON.stringify(data)) + if (completedSuccessfully) error.code = FETCH_RESPONSE_STREAM_FAILED + throw error + } + if (completedSuccessfully) return + if (data?.type === 'message_delta') { + stopReason = data?.delta?.stop_reason || stopReason + return + } if (data?.type === 'message_stop') { + if (stopReason === 'max_tokens') { + completionError = new Error( + 'Claude reached the response token limit. Increase Max Response Token Length and try again.', + ) + } + if (stopReason === 'model_context_window_exceeded') { + completionError = new Error( + 'Claude reached the model context window limit. Clear the conversation and try again.', + ) + } + if (stopReason === 'refusal') { + completionError = new Error('Claude declined to respond to this request.') + } + if (!completionError && !['end_turn', 'stop_sequence'].includes(stopReason)) { + completionError = new Error('Claude response stream ended before completion.') + } + if (completionError) { + controller.abort() + throw completionError + } pushRecord(session, question, answer) console.debug('conversation history', { content: session.conversationRecords }) port.postMessage({ answer: null, done: true, session: session }) + completedSuccessfully = true return } @@ -84,12 +120,10 @@ export async function generateAnswersWithClaudeApi(port, question, session) { }, async onStart() {}, async onEnd(aborted) { + wasAborted = aborted try { - if (!aborted) { - port.postMessage({ done: true }) - } - } finally { port.onMessage.removeListener(messageListener) + } finally { port.onDisconnect.removeListener(disconnectListener) } }, @@ -100,5 +134,16 @@ export async function generateAnswersWithClaudeApi(port, question, session) { const error = await resp.json().catch(() => ({})) throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) }, - }) + }).catch((error) => error) + if (wasAborted) return + if (completionError) throw completionError + if ( + streamError && + (!completedSuccessfully || streamError.code !== FETCH_RESPONSE_STREAM_FAILED) + ) { + throw streamError + } + if (!completedSuccessfully) { + throw new Error('Claude response stream ended before completion.') + } } diff --git a/tests/unit/services/apis/claude-api.test.mjs b/tests/unit/services/apis/claude-api.test.mjs index 88e44ad26..322fe19c4 100644 --- a/tests/unit/services/apis/claude-api.test.mjs +++ b/tests/unit/services/apis/claude-api.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' import { generateAnswersWithClaudeApi } from '../../../../src/services/apis/claude-api.mjs' +import { FETCH_RESPONSE_STREAM_FAILED } from '../../../../src/utils/fetch-sse.mjs' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' @@ -8,6 +9,24 @@ const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) } +const setupCompletionTest = (modelName = 'claudeOpus5Api') => { + setStorage({ + customClaudeApiUrl: 'https://api.anthropic.com', + claudeApiKey: 'sk-ant-test', + maxConversationContextLength: 3, + maxResponseTokenLength: 100, + temperature: 0.5, + }) + return { + session: { + modelName, + conversationRecords: [], + isRetry: false, + }, + port: createFakePort(), + } +} + beforeEach(() => { globalThis.__TEST_BROWSER_SHIM__.clearStorage() }) @@ -36,6 +55,7 @@ test('claude-api: sends correct URL and headers', async (t) => { capturedInit = init return createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]) }) @@ -71,6 +91,7 @@ test('claude-api: sends model, max_tokens, temperature in body', async (t) => { capturedInit = init return createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]) }) @@ -106,6 +127,7 @@ test('claude-api: keeps temperature for Opus 4.6', async (t) => { capturedInit = init return createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]) }) @@ -149,6 +171,7 @@ test('claude-api: omits temperature for models that reject custom sampling', asy capturedInit = init return createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]) }) @@ -190,25 +213,372 @@ test('claude-api: delta.text streams accumulate and message_stop terminates', as createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}\n\n', 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]), ) await generateAnswersWithClaudeApi(port, 'CurrentQ', session) + assert.deepEqual(port.postedMessages, [ + { answer: 'Hel', done: false, session: null }, + { answer: 'Hello', done: false, session: null }, + { answer: null, done: true, session }, + ]) +}) + +test('claude-api: rejects incomplete Claude responses', async (t) => { + t.mock.method(console, 'debug', () => {}) + + for (const { name, modelName, contentEvents, stopReason, error, partialAnswer } of [ + { + name: 'thinking exhausts the response token limit', + modelName: 'claudeOpus5Api', + contentEvents: [ + 'data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"..."}}\n\n', + ], + stopReason: 'max_tokens', + error: /Claude reached the response token limit/, + }, + { + name: 'text is truncated at the response token limit', + modelName: 'claudeSonnet46Api', + contentEvents: [ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ], + stopReason: 'max_tokens', + error: /Claude reached the response token limit/, + partialAnswer: 'Partial', + }, + { + name: 'the model context window is exhausted', + modelName: 'claudeOpus5Api', + contentEvents: [ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ], + stopReason: 'model_context_window_exceeded', + error: /Claude reached the model context window limit/, + partialAnswer: 'Partial', + }, + { + name: 'the request is refused', + modelName: 'claudeHaiku45Api', + contentEvents: [], + stopReason: 'refusal', + error: /Claude declined to respond to this request/, + }, + { + name: 'the turn is paused', + modelName: 'claudeOpus5Api', + contentEvents: [ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ], + stopReason: 'pause_turn', + error: /Claude response stream ended before completion/, + partialAnswer: 'Partial', + }, + { + name: 'the stop reason is unknown', + modelName: 'claudeOpus5Api', + contentEvents: [], + stopReason: 'future_stop_reason', + error: /Claude response stream ended before completion/, + }, + { + name: 'the stop reason is missing', + modelName: 'claudeOpus5Api', + contentEvents: [], + error: /Claude response stream ended before completion/, + }, + ]) { + await t.test(name, async (t) => { + const { session, port } = setupCompletionTest(modelName) + + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + ...contentEvents, + ...(stopReason + ? [`data: {"type":"message_delta","delta":{"stop_reason":"${stopReason}"}}\n\n`] + : []), + 'data: {"type":"message_stop"}\n\n', + ]), + ) + + await assert.rejects(generateAnswersWithClaudeApi(port, 'Q', session), error) + if (partialAnswer) { + assert.equal( + port.postedMessages.some( + (message) => message.done === false && message.answer === partialAnswer, + ), + true, + ) + } + assert.equal( + port.postedMessages.some((message) => message.done === true && message.session === session), + false, + ) + assert.deepEqual(session.conversationRecords, []) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) + } +}) + +test('claude-api: preserves streamed API error details', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + const providerError = { type: 'overloaded_error', message: 'Overloaded' } + const errorEnvelope = { type: 'error', error: providerError, request_id: 'req_test' } + let readCount = 0 + let requestSignal + t.mock.method(globalThis, 'fetch', async (_input, init) => { + requestSignal = init.signal + return { + ok: true, + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount > 1) throw new Error('read after streamed API error') + return { + done: false, + value: new TextEncoder().encode( + `event: error\ndata: ${JSON.stringify(errorEnvelope)}\n\n` + + 'data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"}}\n\n' + + 'data: {"type":"message_stop"}\n\n', + ), + } + }, + } + }, + }, + } + }) + + await assert.rejects(generateAnswersWithClaudeApi(port, 'Q', session), (error) => { + assert.deepEqual(JSON.parse(error.message), errorEnvelope) + return true + }) + assert.equal(readCount, 1) + assert.equal(requestSignal.aborted, true) + assert.deepEqual(session.conversationRecords, []) assert.equal( - port.postedMessages.some((m) => m.done === false && m.answer === 'Hel'), - true, + port.postedMessages.some((message) => message.done === true && message.session === session), + false, ) - assert.equal( - port.postedMessages.some((m) => m.done === false && m.answer === 'Hello'), - true, + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: preserves success before a later streamed API error', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + let requestSignal + t.mock.method(globalThis, 'fetch', async (_input, init) => { + requestSignal = init.signal + return createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Later"}}\n\n' + + 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error"}}\n\n' + + 'data: {"type":"message_stop"}\n\n', + ]) + }) + + await generateAnswersWithClaudeApi(port, 'Q', session) + + assert.equal(requestSignal.aborted, true) + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) + assert.deepEqual(port.postedMessages, [ + { answer: 'Answer', done: false, session: null }, + { answer: null, done: true, session }, + ]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: accepts stop_sequence as a completed response', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"stop_sequence"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ]), ) + + await generateAnswersWithClaudeApi(port, 'Q', session) + + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) assert.equal( - port.postedMessages.some((m) => m.done === true && m.session === session), + port.postedMessages.some((message) => message.done === true && message.session === session), true, ) - assert.deepEqual(port.postedMessages.at(-1), { done: true }) +}) + +test('claude-api: stops an in-progress response without reporting an error', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + t.mock.method(globalThis, 'fetch', async (_input, init) => { + const response = createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ]) + const reader = response.body.getReader() + response.body.getReader = () => ({ + async read() { + const result = await reader.read() + if (result.done) { + port.emitMessage({ stop: true }) + assert.equal(init.signal.aborted, true) + throw new DOMException('Aborted', 'AbortError') + } + return result + }, + }) + return response + }) + + await generateAnswersWithClaudeApi(port, 'Q', session) + + assert.deepEqual(session.conversationRecords, []) + assert.deepEqual(port.postedMessages, [ + { answer: 'Partial', done: false, session: null }, + { done: true }, + ]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: preserves a successful response when the stream later fails', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + const response = createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Later"}}\n\n', + ]) + const reader = response.body.getReader() + response.body.getReader = () => ({ + async read() { + const result = await reader.read() + if (result.done) throw new Error('stream failed after message_stop') + return result + }, + }) + t.mock.method(globalThis, 'fetch', async () => response) + + await generateAnswersWithClaudeApi(port, 'Q', session) + + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) + assert.deepEqual(port.postedMessages, [ + { answer: 'Answer', done: false, session: null }, + { answer: null, done: true, session }, + ]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: rejects a clean EOF before message_stop', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ]), + ) + + await assert.rejects( + generateAnswersWithClaudeApi(port, 'Q', session), + /Claude response stream ended before completion/, + ) + + assert.deepEqual(session.conversationRecords, []) + assert.deepEqual(port.postedMessages, [{ answer: 'Partial', done: false, session: null }]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: propagates callback errors after successful completion', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + port.onMessage.removeListener = () => { + throw new Error('listener cleanup failed') + } + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ]), + ) + + await assert.rejects(generateAnswersWithClaudeApi(port, 'Q', session), /listener cleanup failed/) + assert.equal(port.listenerCounts().onDisconnect, 0) +}) + +test('claude-api: propagates response stream errors before message_stop', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + const response = createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n', + ]) + const reader = response.body.getReader() + response.body.getReader = () => ({ + async read() { + const result = await reader.read() + if (result.done) throw new Error('stream interrupted') + return result + }, + }) + t.mock.method(globalThis, 'fetch', async () => response) + + await assert.rejects(generateAnswersWithClaudeApi(port, 'Q', session), (error) => { + assert.equal(error.code, FETCH_RESPONSE_STREAM_FAILED) + assert.match(error.message, /stream interrupted/) + return true + }) + assert.deepEqual(session.conversationRecords, []) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('claude-api: reports an incomplete stop reason without waiting for EOF', async (t) => { + t.mock.method(console, 'debug', () => {}) + const { session, port } = setupCompletionTest() + let readCount = 0 + let requestSignal + t.mock.method(globalThis, 'fetch', async (_input, init) => { + requestSignal = init.signal + return { + ok: true, + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount > 1) throw new Error('read after incomplete message_stop') + return { + done: false, + value: new TextEncoder().encode( + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}\n\n' + + 'data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"}}\n\n' + + 'data: {"type":"message_stop"}\n\n', + ), + } + }, + } + }, + }, + } + }) + + await assert.rejects( + generateAnswersWithClaudeApi(port, 'Q', session), + /Claude reached the response token limit/, + ) + + assert.equal(readCount, 1) + assert.equal(requestSignal.aborted, true) + assert.deepEqual(session.conversationRecords, []) + assert.deepEqual(port.postedMessages, [{ answer: 'Partial', done: false, session: null }]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) }) test('claude-api: pushRecord on message_stop', async (t) => { @@ -231,6 +601,7 @@ test('claude-api: pushRecord on message_stop', async (t) => { t.mock.method(globalThis, 'fetch', async () => createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]), ) @@ -263,6 +634,7 @@ test('claude-api: cleans up listeners on end', async (t) => { t.mock.method(globalThis, 'fetch', async () => createMockSseResponse([ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]), ) @@ -326,6 +698,7 @@ test('claude-api: ignores unparseable JSON messages', async (t) => { createMockSseResponse([ 'data: not-valid-json\n\n', 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', 'data: {"type":"message_stop"}\n\n', ]), )