chore: retire zen-backed summarization
Disable the active Zen summarization flow because the unauthenticated/free Zen provider is no longer available and now returns usage-limit errors for this feature.
Keep /api/text/summarize as an API-compatible stub that returns local sanitized or distilled fallback text with summarized=false, rather than attempting external model calls.
Remove notification and voice playback summary behavior from runtime paths. Notification {last_message} now always uses normalized truncated text, and TTS playback ignores historical summarize request fields.
Hide the notification summary settings and voice summarize-before-playback controls while preserving legacy persisted settings for compatibility. Also disable Zen model startup validation and make Zen model list routes return empty results.
Update module documentation and tests to describe the retired provider behavior and the remaining compatibility stubs.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Notifications Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module provides notification message preparation utilities for the web server runtime, including text truncation, plain-text normalization, and optional message summarization for system notifications.
|
||||
This module provides notification message preparation utilities for the web server runtime, including text truncation and plain-text normalization for system notifications.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
||||
@@ -9,7 +9,7 @@ This module provides notification message preparation utilities for the web serv
|
||||
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
|
||||
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
|
||||
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
|
||||
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime.
|
||||
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
|
||||
- `packages/web/server/lib/notifications/message.js`: helper implementation module.
|
||||
- `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers.
|
||||
|
||||
@@ -17,7 +17,7 @@ This module provides notification message preparation utilities for the web serv
|
||||
|
||||
### Notifications API (re-exported from message.js)
|
||||
- `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated.
|
||||
- `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support.
|
||||
- `prepareNotificationLastMessage({ message, settings })`: Prepares the last message for notification display by normalizing and truncating text.
|
||||
|
||||
### Route registration API (routes.js)
|
||||
- `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints:
|
||||
@@ -67,14 +67,14 @@ This module provides notification message preparation utilities for the web serv
|
||||
- `broadcastUiNotification(payload)`
|
||||
|
||||
### Template runtime API (template-runtime.js)
|
||||
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime and consumes shared text summarization from `packages/web/server/lib/text/summarization.js` in `notification` mode.
|
||||
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime. Model-backed summarization was retired after the Zen provider became unavailable.
|
||||
- Returned API:
|
||||
- `resolveNotificationTemplate(template, variables)`
|
||||
- `shouldApplyResolvedTemplateMessage(template, resolved, variables)`
|
||||
- `fetchFreeZenModels()`
|
||||
- `resolveZenModel(override)`
|
||||
- `validateZenModelAtStartup()`
|
||||
- `summarizeText(text, targetLength, zenModel)`
|
||||
- `fetchFreeZenModels()` compatibility stub returning `[]`
|
||||
- `resolveZenModel(override)` compatibility stub preserving stored values without validation
|
||||
- `validateZenModelAtStartup()` compatibility no-op
|
||||
- `summarizeText(text, targetLength, zenModel)` compatibility stub returning local fallback text
|
||||
- `extractLastMessageText(payload, maxLength?)`
|
||||
- `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)`
|
||||
- `maybeCacheSessionInfoFromEvent(payload)`
|
||||
@@ -85,16 +85,10 @@ This module provides notification message preparation utilities for the web serv
|
||||
|
||||
### Default values
|
||||
- `DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH`: 250 (default max length for notification text).
|
||||
- `DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD`: 200 (minimum message length to trigger summarization).
|
||||
- `DEFAULT_NOTIFICATION_SUMMARY_LENGTH`: 100 (target length for summarized messages).
|
||||
|
||||
## Settings object format
|
||||
|
||||
The `settings` parameter for `prepareNotificationLastMessage` supports:
|
||||
- `summarizeLastMessage` (boolean): Whether to enable summarization for long messages.
|
||||
- `summaryThreshold` (number): Minimum message length to trigger summarization (default: 200).
|
||||
- `summaryLength` (number): Target length for summarized messages (default: 100).
|
||||
- `maxLastMessageLength` (number): Maximum length for the final notification text (default: 250).
|
||||
The `settings` parameter for `prepareNotificationLastMessage` supports `maxLastMessageLength` (number), the maximum length for the final notification text (default: 250). Legacy summarization settings may still exist in persisted settings but are ignored.
|
||||
|
||||
## Response contracts
|
||||
|
||||
@@ -105,8 +99,7 @@ The `settings` parameter for `prepareNotificationLastMessage` supports:
|
||||
|
||||
### `prepareNotificationLastMessage`
|
||||
- Returns empty string for empty/null message.
|
||||
- Returns truncated original message if summarization disabled, message under threshold, or summarization fails.
|
||||
- Returns truncated summary if summarization succeeds and returns non-empty string.
|
||||
- Returns truncated original message. Model-backed notification summarization is retired.
|
||||
- Normalizes markdown-like formatting to plain text before truncation.
|
||||
- Always applies `maxLastMessageLength` truncation to final result.
|
||||
|
||||
@@ -120,10 +113,10 @@ The `settings` parameter for `prepareNotificationLastMessage` supports:
|
||||
5. Add corresponding unit tests in `packages/web/server/lib/notifications/message.test.js`.
|
||||
|
||||
### Error handling
|
||||
- `prepareNotificationLastMessage` catches summarization errors and falls back to original message.
|
||||
- `prepareNotificationLastMessage` does not call model summarization.
|
||||
- Invalid numeric parameters default to safe fallback values.
|
||||
- Non-string inputs are handled gracefully (return empty string).
|
||||
|
||||
### Testing
|
||||
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|
||||
- Unit tests should cover truncation behavior, summarization success/failure, and edge cases (empty strings, invalid inputs).
|
||||
- Unit tests should cover truncation behavior and edge cases (empty strings, invalid inputs).
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
|
||||
const DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD = 200;
|
||||
const DEFAULT_NOTIFICATION_SUMMARY_LENGTH = 100;
|
||||
|
||||
const resolvePositiveNumber = (value, fallback) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
@@ -42,29 +40,13 @@ export const truncateNotificationText = (text, maxLength = DEFAULT_NOTIFICATION_
|
||||
return `${text.slice(0, safeMaxLength)}...`;
|
||||
};
|
||||
|
||||
export const prepareNotificationLastMessage = async ({ message, settings, summarize }) => {
|
||||
export const prepareNotificationLastMessage = async ({ message, settings }) => {
|
||||
const originalMessage = typeof message === 'string' ? message : '';
|
||||
if (!originalMessage) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const shouldSummarize = settings?.summarizeLastMessage === true && typeof summarize === 'function';
|
||||
const summaryThreshold = resolvePositiveNumber(settings?.summaryThreshold, DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD);
|
||||
const summaryLength = resolvePositiveNumber(settings?.summaryLength, DEFAULT_NOTIFICATION_SUMMARY_LENGTH);
|
||||
const maxLastMessageLength = resolvePositiveNumber(settings?.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH);
|
||||
|
||||
let messageForNotification = originalMessage;
|
||||
if (shouldSummarize && originalMessage.length > summaryThreshold) {
|
||||
try {
|
||||
const summary = await summarize(originalMessage, summaryLength);
|
||||
if (typeof summary === 'string' && summary.trim().length > 0) {
|
||||
messageForNotification = summary;
|
||||
}
|
||||
} catch {
|
||||
messageForNotification = originalMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const plainTextMessage = normalizeNotificationPlainText(messageForNotification) || normalizeNotificationPlainText(originalMessage);
|
||||
const plainTextMessage = normalizeNotificationPlainText(originalMessage);
|
||||
return truncateNotificationText(plainTextMessage, maxLastMessageLength);
|
||||
};
|
||||
|
||||
@@ -7,15 +7,9 @@ describe('notification message helpers', () => {
|
||||
expect(truncateNotificationText('abcdef', 3)).toBe('abc...');
|
||||
});
|
||||
|
||||
it('falls back to original message when summarization fails', async () => {
|
||||
const message = '0123456789';
|
||||
const summarize = async () => {
|
||||
throw new Error('summarization failed');
|
||||
};
|
||||
|
||||
it('ignores retired summarization settings and truncates original message', async () => {
|
||||
const result = await prepareNotificationLastMessage({
|
||||
message,
|
||||
summarize,
|
||||
message: '0123456789',
|
||||
settings: {
|
||||
summarizeLastMessage: true,
|
||||
summaryThreshold: 5,
|
||||
@@ -27,44 +21,10 @@ describe('notification message helpers', () => {
|
||||
expect(result).toBe('0123...');
|
||||
});
|
||||
|
||||
it('falls back to original message when summary is empty', async () => {
|
||||
it('normalizes markdown message to plain text', async () => {
|
||||
const result = await prepareNotificationLastMessage({
|
||||
message: '0123456789',
|
||||
summarize: async () => ' ',
|
||||
message: "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`",
|
||||
settings: {
|
||||
summarizeLastMessage: true,
|
||||
summaryThreshold: 5,
|
||||
summaryLength: 3,
|
||||
maxLastMessageLength: 4,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe('0123...');
|
||||
});
|
||||
|
||||
it('uses summary when summarization succeeds', async () => {
|
||||
const result = await prepareNotificationLastMessage({
|
||||
message: '0123456789',
|
||||
summarize: async () => 'short summary',
|
||||
settings: {
|
||||
summarizeLastMessage: true,
|
||||
summaryThreshold: 5,
|
||||
summaryLength: 3,
|
||||
maxLastMessageLength: 100,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe('short summary');
|
||||
});
|
||||
|
||||
it('normalizes markdown summary to plain text', async () => {
|
||||
const result = await prepareNotificationLastMessage({
|
||||
message: '0123456789',
|
||||
summarize: async () => "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`",
|
||||
settings: {
|
||||
summarizeLastMessage: true,
|
||||
summaryThreshold: 5,
|
||||
summaryLength: 80,
|
||||
maxLastMessageLength: 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
const {
|
||||
readSettingsFromDisk,
|
||||
prepareNotificationLastMessage,
|
||||
summarizeText,
|
||||
resolveZenModel,
|
||||
buildTemplateVariables,
|
||||
extractLastMessageText,
|
||||
fetchLastAssistantMessageText,
|
||||
@@ -248,11 +246,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
|
||||
}
|
||||
|
||||
const notifZenModel = await resolveZenModel(settings?.zenModel);
|
||||
variables.last_message = await prepareNotificationLastMessage({
|
||||
message: lastMessage,
|
||||
settings,
|
||||
summarize: (text, len) => summarizeText(text, len, notifZenModel),
|
||||
});
|
||||
|
||||
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
|
||||
@@ -310,11 +306,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
|
||||
}
|
||||
|
||||
const errZenModel = await resolveZenModel(settings?.zenModel);
|
||||
variables.last_message = await prepareNotificationLastMessage({
|
||||
message: lastMessage,
|
||||
settings,
|
||||
summarize: (text, len) => summarizeText(text, len, errZenModel),
|
||||
});
|
||||
|
||||
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
|
||||
|
||||
@@ -3,20 +3,15 @@ import { summarizeText as summarizeSharedText } from '../text/summarization.js';
|
||||
export const createNotificationTemplateRuntime = (deps) => {
|
||||
const {
|
||||
readSettingsFromDisk,
|
||||
persistSettings,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
resolveGitBinaryForSpawn,
|
||||
} = deps;
|
||||
|
||||
const NOTIFICATION_BODY_MAX_CHARS = 1000;
|
||||
const ZEN_DEFAULT_MODEL = 'gpt-5-nano';
|
||||
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000;
|
||||
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let validatedZenFallback = null;
|
||||
let cachedZenModels = null;
|
||||
let cachedZenModelsTimestamp = 0;
|
||||
const cachedZenModels = { models: [] };
|
||||
|
||||
const sessionTitleCache = new Map();
|
||||
const sessionInfoCache = new Map();
|
||||
@@ -62,116 +57,18 @@ export const createNotificationTemplateRuntime = (deps) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchFreeZenModels = async () => {
|
||||
const now = Date.now();
|
||||
if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) {
|
||||
return cachedZenModels.models;
|
||||
}
|
||||
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
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) => {
|
||||
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();
|
||||
return freeModels;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
const fetchFreeZenModels = async () => [];
|
||||
|
||||
const resolveZenModel = async (override) => {
|
||||
const overrideModel = typeof override === 'string' ? override.trim() : '';
|
||||
let settingsModel = '';
|
||||
try {
|
||||
const settings = await readSettingsFromDisk();
|
||||
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
|
||||
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;
|
||||
if (overrideModel) return overrideModel;
|
||||
const settings = await readSettingsFromDisk().catch(() => ({}));
|
||||
return typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0
|
||||
? settings.zenModel.trim()
|
||||
: '';
|
||||
};
|
||||
|
||||
const validateZenModelAtStartup = async () => {
|
||||
try {
|
||||
const freeModels = await fetchFreeZenModels();
|
||||
const freeModelIds = freeModels.map((model) => model.id);
|
||||
|
||||
if (freeModelIds.length > 0) {
|
||||
validatedZenFallback = freeModelIds[0];
|
||||
|
||||
const settings = await readSettingsFromDisk();
|
||||
const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : '';
|
||||
|
||||
if (!storedModel || !freeModelIds.includes(storedModel)) {
|
||||
const fallback = freeModelIds[0];
|
||||
console.log(
|
||||
storedModel
|
||||
? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"`
|
||||
: `[zen] No model configured, setting default to "${fallback}"`
|
||||
);
|
||||
await persistSettings({ zenModel: fallback });
|
||||
} else {
|
||||
console.log(`[zen] Stored model "${storedModel}" verified as available`);
|
||||
}
|
||||
} else {
|
||||
console.warn('[zen] No free models returned from API, skipping validation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error);
|
||||
}
|
||||
};
|
||||
const validateZenModelAtStartup = async () => {};
|
||||
|
||||
const summarizeText = async (text, targetLength, zenModel) => {
|
||||
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
||||
@@ -179,7 +76,7 @@ export const createNotificationTemplateRuntime = (deps) => {
|
||||
text,
|
||||
threshold: 0,
|
||||
maxLength: targetLength,
|
||||
zenModel: zenModel || ZEN_DEFAULT_MODEL,
|
||||
zenModel,
|
||||
mode: 'notification',
|
||||
});
|
||||
return typeof result?.summary === 'string' && result.summary.trim().length > 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createNotificationTemplateRuntime } from './template-runtime.js';
|
||||
|
||||
@@ -11,78 +11,16 @@ const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
|
||||
});
|
||||
|
||||
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' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
it('returns no selectable zen models after provider retirement', async () => {
|
||||
const runtime = createRuntime();
|
||||
const models = await runtime.fetchFreeZenModels();
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
'big-pickle',
|
||||
'gpt-5-nano',
|
||||
'hy3-preview-free',
|
||||
]);
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
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' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
it('preserves stored zen model value for compatibility without validation', async () => {
|
||||
const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' });
|
||||
|
||||
await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano');
|
||||
await expect(runtime.resolveZenModel()).resolves.toBe('trinity-large-preview-free');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user