diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index ff05e0c3..977f20b7 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -507,13 +507,20 @@ export const TextSelectionMenu: React.FC = ({ containerR try { setIsAddingToNotes(true); - const distilledInsight = await summarizeText(selectedText, { - threshold: 0, - maxLength: 100, - mode: 'note', - }); + let noteText = selectedText; + let usedSummaryFallback = false; + try { + noteText = await summarizeText(selectedText, { + threshold: 0, + maxLength: 100, + mode: 'note', + }); + } catch (summaryError) { + usedSummaryFallback = true; + console.warn('[AddToNotes] Summary failed, saving selected text:', summaryError); + } const projectData = await getProjectNotesAndTodos(currentProjectRef); - const nextNotes = appendDistilledInsightToNotes(projectData.notes, distilledInsight); + const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText); const saved = await saveProjectNotesAndTodos(currentProjectRef, { notes: nextNotes, todos: projectData.todos, @@ -525,7 +532,11 @@ export const TextSelectionMenu: React.FC = ({ containerR window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { detail: { projectId: currentProjectRef.id }, })); - toast.success(t('chat.textSelection.toast.addToNotesSuccess')); + if (usedSummaryFallback) { + toast.warning(t('chat.textSelection.toast.addToNotesSummaryFailed')); + } else { + toast.success(t('chat.textSelection.toast.addToNotesSuccess')); + } hideMenu(); window.getSelection()?.removeAllRanges(); } catch (error) { diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index d5563c56..8f9b1911 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -42,7 +42,6 @@ const TEMPLATE_EVENT_LABEL_KEYS = { question: 'settings.notifications.page.template.event.question', } as const satisfies Record; -const UTILITY_PROVIDER_ID = 'zen'; const UTILITY_PREFERRED_MODEL_ID = 'big-pickle'; const UTILITY_NOT_SELECTED_VALUE = '__not_selected__'; @@ -78,7 +77,6 @@ export const NotificationSettings: React.FC = () => { const setSummaryLength = useUIStore(state => state.setSummaryLength); const maxLastMessageLength = useUIStore(state => state.maxLastMessageLength); const setMaxLastMessageLength = useUIStore(state => state.setMaxLastMessageLength); - const providers = useConfigStore((state) => state.providers); const settingsZenModel = useConfigStore((state) => state.settingsZenModel); const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); @@ -88,27 +86,7 @@ export const NotificationSettings: React.FC = () => { const [pushBusy, setPushBusy] = React.useState(false); const [fetchedZenModels, setFetchedZenModels] = React.useState>([]); - const providerZenModels = React.useMemo(() => { - const zenProvider = providers.find((provider) => provider.id === UTILITY_PROVIDER_ID); - const models = Array.isArray(zenProvider?.models) ? zenProvider.models : []; - return models - .map((model: Record) => { - const id = typeof model.id === 'string' ? model.id.trim() : ''; - if (!id) { - return null; - } - const name = typeof model.name === 'string' && model.name.trim().length > 0 ? model.name.trim() : id; - return { id, name }; - }) - .filter((model): model is { id: string; name: string } => model !== null); - }, [providers]); - React.useEffect(() => { - if (providerZenModels.length > 0) { - setFetchedZenModels([]); - return; - } - const controller = new AbortController(); void fetch('/api/zen/models', { method: 'GET', @@ -145,11 +123,11 @@ export const NotificationSettings: React.FC = () => { return () => { controller.abort(); }; - }, [providerZenModels]); + }, []); const utilityModelOptions = React.useMemo(() => { - return providerZenModels.length > 0 ? providerZenModels : fetchedZenModels; - }, [fetchedZenModels, providerZenModels]); + return fetchedZenModels; + }, [fetchedZenModels]); const utilitySelectedModelId = React.useMemo(() => { if (settingsZenModel && utilityModelOptions.some((model) => model.id === settingsZenModel)) { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 72f66314..ad40bf60 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1366,6 +1366,7 @@ export const dict = { 'chat.textSelection.toast.noProject': 'No project found for this session', 'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes', 'chat.textSelection.toast.addToNotesSuccess': 'Added distilled insight to notes', + 'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes', 'chat.textSelection.actions.addToChat': 'Add to chat', 'chat.textSelection.actions.newSession': 'New session', 'chat.textSelection.actions.copy': 'Copy', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index af350814..d159b9d9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1332,6 +1332,7 @@ export const dict: Record = { "chat.textSelection.toast.noProject": "No se encontró proyecto para esta sesión", "chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas", "chat.textSelection.toast.addToNotesSuccess": "Se añadió la información destilada a notas", + "chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas", "chat.textSelection.actions.addToChat": "Añadir al chat", "chat.textSelection.actions.newSession": "Nueva sesión", "chat.textSelection.actions.copy": "Copiar", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index feb68edb..a0e02de2 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1368,6 +1368,7 @@ export const dict: Record = { 'chat.textSelection.toast.noProject': '이 세션의 프로젝트를 찾을 수 없음', 'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패', 'chat.textSelection.toast.addToNotesSuccess': '정리된 인사이트를 메모에 추가함', + 'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함', 'chat.textSelection.actions.addToChat': '채팅에 추가', 'chat.textSelection.actions.newSession': '새 세션', 'chat.textSelection.actions.copy': '복사', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 385c4ffa..70b8b6d2 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1332,6 +1332,7 @@ export const dict: Record = { "chat.textSelection.toast.noProject": "Não foi encontrado projeto para esta sessão", "chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas", "chat.textSelection.toast.addToNotesSuccess": "Informação destilada adicionada às notas", + "chat.textSelection.toast.addToNotesSummaryFailed": "Não foi possível resumir a seleção; o texto selecionado foi adicionado às notas", "chat.textSelection.actions.addToChat": "Adicionar ao chat", "chat.textSelection.actions.newSession": "Nova sessão", "chat.textSelection.actions.copy": "Copiar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 70344b80..c4e9bda5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1332,6 +1332,7 @@ export const dict: Record = { "chat.textSelection.toast.noProject": "Для цієї сесії не знайдено жодного проєкту", "chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток", "chat.textSelection.toast.addToNotesSuccess": "Інсайт додано до нотаток", + "chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток", "chat.textSelection.actions.addToChat": "Додати в чат", "chat.textSelection.actions.newSession": "Нова сесія", "chat.textSelection.actions.copy": "Копіювати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index ee0f74b2..38c8126d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1332,6 +1332,7 @@ export const dict: Record = { 'chat.textSelection.toast.noProject': '未找到此会话对应的项目', 'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败', 'chat.textSelection.toast.addToNotesSuccess': '已将洞察添加到笔记', + 'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记', 'chat.textSelection.actions.addToChat': '添加到聊天', 'chat.textSelection.actions.newSession': '新建会话', 'chat.textSelection.actions.copy': '复制', diff --git a/packages/ui/src/lib/voice/summarize.ts b/packages/ui/src/lib/voice/summarize.ts index 3b78d193..6b6a55e2 100644 --- a/packages/ui/src/lib/voice/summarize.ts +++ b/packages/ui/src/lib/voice/summarize.ts @@ -52,13 +52,12 @@ export async function summarizeText( } try { - const zenModel = store.settingsZenModel; const response = await fetch(resolveSummarizeUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ text, threshold, maxLength, mode, ...(zenModel ? { zenModel } : {}) }), + body: JSON.stringify({ text, threshold, maxLength, mode }), }); if (!response.ok) { diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index c50d9be7..4cc78dbe 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -210,17 +210,31 @@ const fetchFreeZenModels = async (): Promise null); + const rawMetadata = await metadataResponse.json().catch(() => null); const payload = asObject(rawPayload); + const metadata = asObject(rawMetadata); + const metadataProvider = asObject(metadata?.opencode); + const metadataModels = asObject(metadataProvider?.models); const rows = Array.isArray(payload?.data) ? payload.data : []; const models = rows .map((entry) => { @@ -230,7 +244,9 @@ const fetchFreeZenModels = async (): Promise entry !== null); diff --git a/packages/web/server/lib/notifications/template-runtime.js b/packages/web/server/lib/notifications/template-runtime.js index 0b628aed..58698b01 100644 --- a/packages/web/server/lib/notifications/template-runtime.js +++ b/packages/web/server/lib/notifications/template-runtime.js @@ -71,18 +71,36 @@ export const createNotificationTemplateRuntime = (deps) => { const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; try { - const response = await fetch('https://opencode.ai/zen/v1/models', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`zen/v1/models responded with status ${response.status}`); + const [zenResponse, metadataResponse] = await Promise.all([ + fetch('https://opencode.ai/zen/v1/models', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }), + fetch('https://models.dev/api.json', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }), + ]); + if (!zenResponse.ok) { + throw new Error(`zen/v1/models responded with status ${zenResponse.status}`); } - const data = await response.json(); + if (!metadataResponse.ok) { + throw new Error(`models.dev responded with status ${metadataResponse.status}`); + } + + const data = await zenResponse.json(); + const metadata = await metadataResponse.json(); + const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object' + ? metadata.opencode.models + : {}; const allModels = Array.isArray(data?.data) ? data.data : []; const freeModels = allModels - .filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free')) - .map((model) => ({ id: model.id, owned_by: model.owned_by })); + .filter((model) => { + const id = typeof model?.id === 'string' ? model.id.trim() : ''; + const cost = id ? metadataModels[id]?.cost : null; + return id && cost?.input === 0 && cost?.output === 0; + }) + .map((model) => ({ id: model.id.trim(), owned_by: model.owned_by })); cachedZenModels = { models: freeModels }; cachedZenModelsTimestamp = Date.now(); @@ -93,16 +111,35 @@ export const createNotificationTemplateRuntime = (deps) => { }; const resolveZenModel = async (override) => { - if (typeof override === 'string' && override.trim().length > 0) { - return override.trim(); - } + const overrideModel = typeof override === 'string' ? override.trim() : ''; + let settingsModel = ''; try { const settings = await readSettingsFromDisk(); if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { - return settings.zenModel.trim(); + settingsModel = settings.zenModel.trim(); } } catch { } + + const candidate = overrideModel || settingsModel; + try { + const models = await fetchFreeZenModels(); + const modelIds = models.map((model) => model.id); + if (candidate && modelIds.includes(candidate)) { + return candidate; + } + if (modelIds.includes(ZEN_DEFAULT_MODEL)) { + return ZEN_DEFAULT_MODEL; + } + if (modelIds.length > 0) { + return modelIds[0]; + } + } catch { + if (candidate) { + return candidate; + } + } + return validatedZenFallback || ZEN_DEFAULT_MODEL; }; diff --git a/packages/web/server/lib/notifications/template-runtime.test.js b/packages/web/server/lib/notifications/template-runtime.test.js new file mode 100644 index 00000000..22c225bd --- /dev/null +++ b/packages/web/server/lib/notifications/template-runtime.test.js @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createNotificationTemplateRuntime } from './template-runtime.js'; + +const createRuntime = (settings = {}) => createNotificationTemplateRuntime({ + readSettingsFromDisk: async () => settings, + persistSettings: vi.fn(async () => {}), + buildOpenCodeUrl: (path) => path, + getOpenCodeAuthHeaders: () => ({}), + resolveGitBinaryForSpawn: () => 'git', +}); + +describe('notification template runtime zen models', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses zen models with zero-cost metadata as selectable', async () => { + vi.stubGlobal('fetch', vi.fn(async (url) => { + if (String(url).includes('models.dev')) { + return { + ok: true, + json: async () => ({ + opencode: { + models: { + 'big-pickle': { cost: { input: 0, output: 0 } }, + 'gpt-5-nano': { cost: { input: 0, output: 0 } }, + 'gpt-5.5': { cost: { input: 5, output: 30 } }, + 'hy3-preview-free': { cost: { input: 0, output: 0 } }, + }, + }, + }), + }; + } + return { + ok: true, + json: async () => ({ + data: [ + { id: 'big-pickle', owned_by: 'opencode' }, + { id: 'gpt-5-nano', owned_by: 'opencode' }, + { id: 'gpt-5.5', owned_by: 'opencode' }, + { id: 'hy3-preview-free', owned_by: 'opencode' }, + ], + }), + }; + })); + + const runtime = createRuntime(); + const models = await runtime.fetchFreeZenModels(); + + expect(models.map((model) => model.id)).toEqual([ + 'big-pickle', + 'gpt-5-nano', + 'hy3-preview-free', + ]); + }); + + it('falls back to a valid unauthenticated model when stored zen model is stale', async () => { + vi.stubGlobal('fetch', vi.fn(async (url) => { + if (String(url).includes('models.dev')) { + return { + ok: true, + json: async () => ({ + opencode: { + models: { + 'big-pickle': { cost: { input: 0, output: 0 } }, + 'gpt-5-nano': { cost: { input: 0, output: 0 } }, + }, + }, + }), + }; + } + return { + ok: true, + json: async () => ({ + data: [ + { id: 'big-pickle', owned_by: 'opencode' }, + { id: 'gpt-5-nano', owned_by: 'opencode' }, + ], + }), + }; + })); + + const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' }); + + await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano'); + }); +}); diff --git a/packages/web/server/lib/text/summarization.js b/packages/web/server/lib/text/summarization.js index 231ce2c7..3b9f2fcb 100644 --- a/packages/web/server/lib/text/summarization.js +++ b/packages/web/server/lib/text/summarization.js @@ -131,6 +131,42 @@ function extractZenOutputText(data) { return text || null; } +function extractZenChatCompletionText(data) { + if (!data || typeof data !== 'object') return null; + const choices = data.choices; + if (!Array.isArray(choices)) return null; + + const choice = choices.find((item) => item && typeof item === 'object'); + const content = choice?.message?.content; + if (typeof content === 'string') { + const text = content.trim(); + return text || null; + } + if (!Array.isArray(content)) return null; + + const text = content + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object' && typeof item.text === 'string') return item.text; + return ''; + }) + .join('') + .trim(); + return text || null; +} + +function getZenCompletionEndpoint(model) { + if (typeof model !== 'string') return 'responses'; + if ( + model.startsWith('gpt-') + || model.startsWith('claude-') + || model.startsWith('gemini-') + ) { + return 'responses'; + } + return 'chat/completions'; +} + function distillNoteFallback(text, maxLength) { const sanitized = sanitizeForNote(text); if (!sanitized) return ''; @@ -176,15 +212,23 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze try { const prompt = buildSummarizationPrompt(maxLength, mode); - const response = await fetch('https://opencode.ai/zen/v1/responses', { + const model = zenModel || 'gpt-5-nano'; + const endpoint = getZenCompletionEndpoint(model); + const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel || 'gpt-5-nano', - input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], - stream: false, - reasoning: { effort: 'low' }, - }), + body: JSON.stringify(endpoint === 'responses' + ? { + model, + input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], + stream: false, + reasoning: { effort: 'low' }, + } + : { + model, + messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], + stream: false, + }), signal: controller.signal, }); @@ -199,7 +243,9 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze } const data = await response.json(); - const summary = extractZenOutputText(data); + const summary = endpoint === 'responses' + ? extractZenOutputText(data) + : extractZenChatCompletionText(data); if (summary) { const sanitized = sanitizeByMode(summary, mode); diff --git a/packages/web/server/lib/text/summarization.test.js b/packages/web/server/lib/text/summarization.test.js new file mode 100644 index 00000000..889f14e4 --- /dev/null +++ b/packages/web/server/lib/text/summarization.test.js @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { summarizeText } from './summarization.js'; + +describe('text summarization zen requests', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses responses endpoint for gpt models', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + output: [{ + type: 'message', + content: [{ type: 'output_text', text: 'Short summary' }], + }], + }), + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await summarizeText({ + text: 'Long text '.repeat(30), + threshold: 0, + maxLength: 100, + zenModel: 'gpt-5-nano', + mode: 'notification', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://opencode.ai/zen/v1/responses', + expect.objectContaining({ + body: expect.stringContaining('"input"'), + }), + ); + expect(result.summary).toBe('Short summary'); + }); + + it('uses chat completions endpoint for openai-compatible zen models', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Chat summary' } }], + }), + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await summarizeText({ + text: 'Long text '.repeat(30), + threshold: 0, + maxLength: 100, + zenModel: 'big-pickle', + mode: 'notification', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://opencode.ai/zen/v1/chat/completions', + expect.objectContaining({ + body: expect.stringContaining('"messages"'), + }), + ); + expect(result.summary).toBe('Chat summary'); + }); +}); diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index fef720ba..7faf462d 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -1,6 +1,6 @@ import express from 'express'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; -import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js'; +import { summarizeText, sanitizeForTTS, sanitizeForNote, sanitizeForNotification } from '../text/summarization.js'; export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { let ttsModulePromise = null; @@ -124,7 +124,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { } const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ + let result = await summarizeText({ text, threshold, maxLength, @@ -132,6 +132,27 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { mode: typeof mode === 'string' ? mode : 'tts', }); + if (mode === 'note' && !result.summarized) { + const notificationResult = await summarizeText({ + text, + threshold, + maxLength, + zenModel: sumZenModel, + mode: 'notification', + }); + if (notificationResult.summarized && notificationResult.summary) { + result = { + ...notificationResult, + summary: sanitizeForNote(sanitizeForNotification(notificationResult.summary)), + }; + } else { + return res.status(502).json({ + error: 'Note summarization failed', + reason: notificationResult.reason || result.reason || 'No distilled result from model', + }); + } + } + return res.json(result); } catch (error) { console.error('[Summarize] Error:', error); diff --git a/packages/web/server/lib/tts/routes.test.js b/packages/web/server/lib/tts/routes.test.js new file mode 100644 index 00000000..abc4b7cc --- /dev/null +++ b/packages/web/server/lib/tts/routes.test.js @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +import { registerTtsRoutes } from './routes.js'; + +const createApp = () => { + const app = express(); + app.use(express.json()); + registerTtsRoutes(app, { + resolveZenModel: async () => 'gpt-5-nano', + sayTTSCapability: null, + }); + return app; +}; + +describe('tts routes', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('retries note summarization with notification mode before failing', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + }))); + + const response = await request(createApp()) + .post('/api/text/summarize') + .send({ + text: 'First sentence. Second sentence with the useful insight.', + threshold: 0, + maxLength: 100, + mode: 'note', + }); + + expect(response.status).toBe(502); + expect(fetch).toHaveBeenCalledTimes(2); + expect(response.body).toEqual({ + error: 'Note summarization failed', + reason: 'zen API returned 503', + }); + }); + + it('uses notification summarizer result when note mode falls back', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + output: [{ + type: 'message', + content: [{ type: 'output_text', text: '**Keep provider state stable** during streaming.' }], + }], + }), + })); + + const response = await request(createApp()) + .post('/api/text/summarize') + .send({ + text: 'First sentence. Preserve provider state references during streaming to avoid wide rerenders.', + threshold: 0, + maxLength: 100, + mode: 'note', + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + summary: 'Keep provider state stable during streaming.', + summarized: true, + }); + }); + + it('keeps notification fallback behavior', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + }))); + + const response = await request(createApp()) + .post('/api/text/summarize') + .send({ + text: 'Notification text that should fall back cleanly.', + threshold: 0, + maxLength: 100, + mode: 'notification', + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + summary: 'Notification text that should fall back cleanly.', + summarized: false, + reason: 'zen API returned 503', + }); + }); +});