CI / build + unit (pull_request) Successful in 1m20s
The single-message summary was too small (owner request). The summarizer prompt now asks for exactly 3-5 lines, one item per line, in the format '<short header>: <one or two short sentences>' with a <=20-char header, no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom). The reply is parsed into items: drop empty lines and lines without a ':' header separator, cap at 5; fewer than 3 valid lines sends what we got (no failure/retry), zero valid lines sends nothing. Each item goes out as its own mesh message via respond(id, line) - all threaded to the trigger packet id on its channel. Per-item backstop fitOneLine() hard-cuts any line >185 chars at a word boundary near 180 so every item fits one packet; the old whole-reply fitOnePacket() cut is gone. CommandService sends array replies line by line through the same routing as single replies (respond per line, or legacy sendText fallback per line when there is no packet id). LLM failure path unchanged: single 'summary failed' reply.
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { LlmService } from '../llm/llm.service.js';
|
|
import { RssStore } from './rss.store.js';
|
|
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
|
|
|
|
const FEED_A = `<rss version="2.0"><channel>
|
|
<item><title>Alpha headline</title></item>
|
|
<item><title>Beta headline</title></item>
|
|
</channel></rss>`;
|
|
const FEED_B = `<rss version="2.0"><channel>
|
|
<item><title>Gamma headline</title></item>
|
|
</channel></rss>`;
|
|
|
|
function feedResponse(xml: string): Response {
|
|
return { ok: true, status: 200, text: async () => xml } as Response;
|
|
}
|
|
|
|
describe('RssService.handle', () => {
|
|
let dir: string;
|
|
let dbFile: string;
|
|
let llm: { chat: ReturnType<typeof vi.fn> };
|
|
let svc: RssService;
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-svc-'));
|
|
dbFile = join(dir, 'test.db');
|
|
llm = { chat: vi.fn() };
|
|
svc = new RssService(
|
|
llm as unknown as LlmService,
|
|
new DatabaseSync(dbFile),
|
|
);
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const ctx = (text: string) => ({ text });
|
|
// second connection — the same shape production uses for a separate table in one file
|
|
const stored = () => new RssStore(new DatabaseSync(dbFile));
|
|
|
|
it('replies with usage on bare #rss and on unknown subcommands', async () => {
|
|
const help = await svc.handle(ctx('#rss'));
|
|
expect(help).toContain('#rss add <url> <name>');
|
|
expect(help).toContain('#rss get <name|#tag>');
|
|
expect(await svc.handle(ctx('#rss frobnicate'))).toBe(help);
|
|
});
|
|
|
|
it('add: validates the feed, stores source with LLM tags, replies added (tags: ...)', async () => {
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
llm.chat.mockResolvedValueOnce('#AI, News');
|
|
|
|
const reply = await svc.handle(
|
|
ctx('#rss add https://a.example/rss my feed'),
|
|
);
|
|
|
|
expect(reply).toBe('added my feed (tags: ai, news)');
|
|
expect(stored().get('my feed')).toMatchObject({
|
|
url: 'https://a.example/rss',
|
|
tags: ['ai', 'news'],
|
|
});
|
|
const [messages, opts] = llm.chat.mock.calls[0];
|
|
expect(opts).toEqual({ maxTokens: 50 });
|
|
expect(messages[0].content).toContain('Alpha headline');
|
|
});
|
|
|
|
it('add: LLM failure still stores the source with empty tags and says so', async () => {
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
llm.chat.mockRejectedValueOnce(new Error('llm down'));
|
|
|
|
const reply = await svc.handle(
|
|
ctx('#rss add https://a.example/rss myfeed'),
|
|
);
|
|
|
|
expect(reply).toBe('added myfeed (tag generation failed - no tags)');
|
|
expect(stored().get('myfeed')?.tags).toEqual([]);
|
|
});
|
|
|
|
it('add: rejects invalid urls without fetching or storing', async () => {
|
|
expect(await svc.handle(ctx('#rss add not a url x'))).toBe('invalid url');
|
|
expect(await svc.handle(ctx('#rss add ftp://a.example/rss x'))).toBe(
|
|
'invalid url',
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(stored().list()).toEqual([]);
|
|
});
|
|
|
|
it('add: rejects duplicate names case-insensitively without fetching', async () => {
|
|
stored().add({ name: 'MyFeed', url: 'https://old.example/rss', tags: [] });
|
|
|
|
const reply = await svc.handle(
|
|
ctx('#rss add https://a.example/rss myfeed'),
|
|
);
|
|
|
|
expect(reply).toBe('myfeed already exists - remove it first');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(stored().get('myfeed')?.url).toBe('https://old.example/rss'); // untouched
|
|
});
|
|
|
|
it('add: reports non-2xx and unparseable feeds, stores nothing', async () => {
|
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
|
expect(await svc.handle(ctx('#rss add https://a.example/rss x'))).toBe(
|
|
'feed fetch failed (HTTP 404)',
|
|
);
|
|
|
|
fetchMock.mockResolvedValueOnce(feedResponse('this is not xml'));
|
|
expect(await svc.handle(ctx('#rss add https://b.example/rss y'))).toBe(
|
|
'not a valid feed (no items)',
|
|
);
|
|
expect(stored().list()).toEqual([]);
|
|
});
|
|
|
|
it('remove: deletes case-insensitively, reports missing names', async () => {
|
|
stored().add({ name: 'Gone', url: 'https://g.example/rss', tags: [] });
|
|
expect(await svc.handle(ctx('#rss remove gone'))).toBe('removed gone');
|
|
expect(stored().list()).toEqual([]);
|
|
expect(await svc.handle(ctx('#rss remove nope'))).toBe(
|
|
'no source named nope',
|
|
);
|
|
});
|
|
|
|
it('tags: no tags yet when empty, distinct sorted list otherwise', async () => {
|
|
expect(await svc.handle(ctx('#rss tags'))).toBe('no tags yet');
|
|
stored().add({
|
|
name: 'A',
|
|
url: 'https://a.example/rss',
|
|
tags: ['zeta', 'alpha'],
|
|
});
|
|
stored().add({
|
|
name: 'B',
|
|
url: 'https://b.example/rss',
|
|
tags: ['beta', 'alpha'],
|
|
});
|
|
expect(await svc.handle(ctx('#rss tags'))).toBe('alpha, beta, zeta');
|
|
});
|
|
|
|
it('sources: no sources yet when empty, names otherwise', async () => {
|
|
expect(await svc.handle(ctx('#rss sources'))).toBe('no sources yet');
|
|
stored().add({ name: 'Bee', url: 'https://b.example/rss', tags: [] });
|
|
stored().add({ name: 'Aye', url: 'https://a.example/rss', tags: [] });
|
|
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
|
|
});
|
|
|
|
it('get <name>: summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
|
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
llm.chat.mockResolvedValueOnce(
|
|
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
|
|
);
|
|
|
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
|
|
|
expect(reply).toEqual([
|
|
'Chips: EU funds foundries.',
|
|
'Park: Riverfront expansion approved.',
|
|
]);
|
|
const [messages, opts] = llm.chat.mock.calls[0];
|
|
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
|
|
expect(opts).toEqual({ maxTokens: 2500 });
|
|
const prompt = messages[0].content;
|
|
expect(prompt).toContain('exactly 3-5 lines');
|
|
expect(prompt).toContain('<short header>: <one or two short sentences>');
|
|
expect(prompt).toContain('at most 20 characters');
|
|
expect(prompt).toContain('No intro, no outro, no numbering');
|
|
expect(prompt).toContain('Alpha headline');
|
|
expect(prompt).toContain('Beta headline');
|
|
});
|
|
|
|
it('get <name>: caps items at 5', async () => {
|
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
|
|
.map((c) => `${c}: item ${c}`)
|
|
.join('\n');
|
|
llm.chat.mockResolvedValueOnce(seven);
|
|
|
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
|
|
|
expect(reply).toEqual([
|
|
'a: item a',
|
|
'b: item b',
|
|
'c: item c',
|
|
'd: item d',
|
|
'e: item e',
|
|
]);
|
|
});
|
|
|
|
it('get <name>: drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
|
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
llm.chat.mockResolvedValueOnce(
|
|
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
|
|
);
|
|
|
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
|
|
|
expect(reply).toEqual([
|
|
'Chips: EU funds foundries.',
|
|
'Park: expansion approved.',
|
|
]);
|
|
});
|
|
|
|
it('get <name>: hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
|
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
|
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
|
|
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
|
|
|
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
|
|
|
expect(Array.isArray(reply)).toBe(true);
|
|
const [cut, ok] = reply as string[];
|
|
expect(cut.length).toBeLessThanOrEqual(180);
|
|
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
|
|
expect(cut.endsWith(' ')).toBe(false);
|
|
expect(ok).toBe('Park: short line.'); // other lines untouched
|
|
});
|
|
|
|
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
|
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
|
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
|
|
stored().add({ name: 'C', url: 'https://c.example/rss', tags: ['other'] }); // not matched by #tech
|
|
fetchMock
|
|
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
|
|
.mockRejectedValueOnce(new Error('down')); // B is dead
|
|
|
|
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
|
|
const reply = await svc.handle(ctx('#rss get #tech'));
|
|
|
|
// B's fetch rejected, yet the summary still went out from A's titles alone
|
|
expect(reply).toEqual(['Chips: only alpha survived.']);
|
|
expect(llm.chat).toHaveBeenCalledTimes(1);
|
|
const content = llm.chat.mock.calls[0][0][0].content;
|
|
expect(content).toContain('Alpha headline');
|
|
});
|
|
|
|
it('get #tag: merges titles from every source carrying the tag', async () => {
|
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
|
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
|
|
fetchMock
|
|
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
|
|
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
|
|
|
|
llm.chat.mockResolvedValueOnce(
|
|
'Alpha: first headline.\nGamma: second headline.',
|
|
);
|
|
const reply = await svc.handle(ctx('#rss get #tech'));
|
|
|
|
expect(reply).toEqual([
|
|
'Alpha: first headline.',
|
|
'Gamma: second headline.',
|
|
]);
|
|
const content = llm.chat.mock.calls[0][0][0].content;
|
|
expect(content).toContain('Alpha headline');
|
|
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
|
|
});
|
|
|
|
it('get: unknown name and tag get distinct error replies', async () => {
|
|
expect(await svc.handle(ctx('#rss get nope'))).toBe('no source named nope');
|
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
|
expect(await svc.handle(ctx('#rss get #nothing'))).toBe(
|
|
'no sources with tag nothing',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
|
|
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
|
|
expect(
|
|
parseSummaryItems(
|
|
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
|
|
),
|
|
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
|
|
});
|
|
|
|
it('caps at 5 items', () => {
|
|
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
|
|
.map((c) => `${c}: item`)
|
|
.join('\n');
|
|
expect(parseSummaryItems(seven)).toHaveLength(5);
|
|
expect(parseSummaryItems(seven)[4]).toBe('e: item');
|
|
});
|
|
|
|
it('returns [] when nothing is valid', () => {
|
|
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('fitOneLine (one mesh packet per line)', () => {
|
|
it('passes through lines that already fit, including exactly 185 chars', () => {
|
|
const short = 'Chips: EU funds foundries.';
|
|
expect(fitOneLine(short)).toBe(short);
|
|
const exact = 'x'.repeat(185);
|
|
expect(fitOneLine(exact)).toBe(exact);
|
|
});
|
|
|
|
it('cuts over-long lines at a word boundary near 180', () => {
|
|
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
|
|
const cut = fitOneLine(long);
|
|
expect(cut.length).toBeLessThanOrEqual(180);
|
|
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
|
|
expect(cut.endsWith(' ')).toBe(false);
|
|
});
|
|
|
|
it('hard-cuts at 180 when there is no word boundary in reach', () => {
|
|
const long = 'w'.repeat(250);
|
|
expect(fitOneLine(long)).toBe('w'.repeat(180));
|
|
});
|
|
});
|