From ab9e364fb606f6227d7fb7a04dc75fb8a18be44f Mon Sep 17 00:00:00 2001 From: damengrandom Date: Sat, 1 Aug 2026 19:38:39 +1000 Subject: [PATCH 1/2] fix(T-34): record only the repos the trending digest actually delivered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTrendingMessage drops trailing repos when the digest would exceed Telegram's 4096-char limit. That truncation is deliberate and tested — without it Telegram rejects the whole message. The bug is what happened next. The drop was invisible outside the tool: it returned { success: true } either way, so runNewsAgent persisted the full, undropped list with sent = true. Repos that were never in the outgoing message were recorded as delivered, and since the DB is the only record of what went out, nothing afterwards could tell them apart. fitRepos now exposes which repos survived, the tool returns their names and logs a warning when it drops any, and saveTrending tags each repo with whether it was actually delivered. Against issue #26's own reproduction: owner0..3/project-* sent=true delivered=true owner4..7/project-* sent=false delivered=false rows recorded sent=true but never delivered: 0 (was 4) WARN ✂️ Digest too long for Telegram — dropped 4 of 8 repos: owner4/project-4, ... sendTelegram returns the delivered names instead of a boolean; a failed send returns an empty set, so nothing is marked sent — same behaviour as before for that path. Closes #26 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u --- src/agent/index.ts | 26 +++++++++++++++----------- src/tools/news-telegram.test.ts | 30 +++++++++++++++++++++++++++++- src/tools/news-telegram.tool.ts | 24 ++++++++++++++++++++---- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 53d8a4d..0da6b10 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -178,22 +178,26 @@ export class WorkCoordinator { return curateTrending(newRepos, feedback) } - // Step 4: deliver the digest via Telegram. Returns whether the send succeeded. - private static async sendTelegram(repos: CuratedRepo[]): Promise { + // Step 4: deliver the digest via Telegram. Returns the repo names that actually + // went out — the tool drops trailing repos when the digest is too long, and + // those were never delivered. + private static async sendTelegram(repos: CuratedRepo[]): Promise> { logger.info('⚡️ Sending trending digest via Telegram...') try { - await trendingTelegramTool.invoke({ repos }) - return true + const raw = await trendingTelegramTool.invoke({ repos }) + const { delivered } = parseJson<{ delivered?: string[] }>(raw, {}) + + return new Set(delivered ?? []) } catch (err) { logger.error({ err }, '❌ Telegram delivery failed') await notifyError('Trending Telegram delivery', err) - return false + return new Set() } } - // Step 5: persist the curated repos, tagging whether delivery succeeded. - private static async saveTrending(repos: CuratedRepo[], sent: boolean, now: string): Promise { + // Step 5: persist the curated repos, tagging each with whether it was delivered. + private static async saveTrending(repos: CuratedRepo[], delivered: Set, now: string): Promise { try { await saveTrendingRepos( repos.map((r) => ({ @@ -205,12 +209,12 @@ export class WorkCoordinator { today_stars: r.today_stars, summary: r.summary, tags: r.tags, - sent, + sent: delivered.has(r.repo_name), created_at: now, updated_at: now, })) ) - logger.info(`✅ Saved ${repos.length} trending repos to database.`) + logger.info(`✅ Saved ${repos.length} trending repos to database (${delivered.size} marked sent).`) } catch (err) { logger.error({ err }, '❌ Failed to save trending repos') await notifyError('saveTrendingRepos', err) @@ -366,10 +370,10 @@ export class WorkCoordinator { if (!curated) return // ── Step 4: Send via Telegram ─────────────────────────────────────────── - const sent = await WorkCoordinator.sendTelegram(curated) + const delivered = await WorkCoordinator.sendTelegram(curated) // ── Step 5: Save to DB ────────────────────────────────────────────────── - await WorkCoordinator.saveTrending(curated, sent, now) + await WorkCoordinator.saveTrending(curated, delivered, now) sectionLogger(`✅ GitHub Trending job complete for ${today}`) } diff --git a/src/tools/news-telegram.test.ts b/src/tools/news-telegram.test.ts index f50135d..1057e64 100644 --- a/src/tools/news-telegram.test.ts +++ b/src/tools/news-telegram.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { buildTrendingMessage } from './news-telegram.tool.ts' +import { buildTrendingMessage, fitRepos } from './news-telegram.tool.ts' import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX, TRENDING_TOP_N } from '../constants/index.ts' import type { CuratedRepo } from '../schemas/index.ts' @@ -90,3 +90,31 @@ test('drops trailing repos rather than emitting a message Telegram will reject', assert.ok(message.length <= TELEGRAM_MAX_CHARS, `message was ${message.length} chars`) }) + +// A dropped repo is never delivered, so the caller has to be able to tell which +// ones went out — otherwise they get recorded as sent. +test('reports which repos survived the drop, not just the message', () => { + const bomb = Array.from({ length: TRENDING_TOP_N }, (_, i) => + repo({ repo_name: `owner${i}/project-${i}`, summary: '&'.repeat(TRENDING_SUMMARY_MAX) }) + ) + + const kept = fitRepos(bomb, '2026-08-01') + + assert.ok(kept.length < bomb.length, 'this input must actually trigger the drop') + assert.equal(buildTrendingMessage(bomb, '2026-08-01'), assembleOf(kept)) + + // Every kept repo appears in the message; every dropped one does not. + const message = buildTrendingMessage(bomb, '2026-08-01') + const dropped = bomb.slice(kept.length) + + for (const r of kept) assert.ok(message.includes(r.repo_name), `${r.repo_name} should be in the digest`) + for (const r of dropped) assert.ok(!message.includes(r.repo_name), `${r.repo_name} was dropped and must not appear`) +}) + +test('keeps every repo when the digest fits', () => { + const fits = [repo(), repo({ repo_name: 'vuejs/core' })] + + assert.equal(fitRepos(fits, '2026-08-01').length, 2) +}) + +const assembleOf = (kept: CuratedRepo[]) => buildTrendingMessage(kept, '2026-08-01') diff --git a/src/tools/news-telegram.tool.ts b/src/tools/news-telegram.tool.ts index 0b43b35..4596f78 100644 --- a/src/tools/news-telegram.tool.ts +++ b/src/tools/news-telegram.tool.ts @@ -1,5 +1,6 @@ import { DynamicStructuredTool } from '@langchain/core/tools' import { z } from 'zod' +import { logger } from '../utils/logger.js' import { escapeHtml, truncate } from '../agent/utils.js' import { sendTelegramMessage } from './telegram.js' import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX } from '../constants/index.js' @@ -42,12 +43,16 @@ function assemble(repos: CuratedRepo[], today: string): string { ].join('\n') } -export function buildTrendingMessage(repos: CuratedRepo[], today: string): string { +export function fitRepos(repos: CuratedRepo[], today: string): CuratedRepo[] { let kept = repos while (kept.length > 1 && assemble(kept, today).length > TELEGRAM_MAX_CHARS) kept = kept.slice(0, -1) - return assemble(kept, today) + return kept +} + +export function buildTrendingMessage(repos: CuratedRepo[], today: string): string { + return assemble(fitRepos(repos, today), today) } export const trendingTelegramTool = new DynamicStructuredTool({ @@ -71,8 +76,19 @@ export const trendingTelegramTool = new DynamicStructuredTool({ }), func: async ({ repos }) => { const today = new Date().toISOString().split('T')[0] - const chatId = await sendTelegramMessage(buildTrendingMessage(repos, today), 'Trending repos') + const kept = fitRepos(repos, today) + + if (kept.length < repos.length) { + logger.warn( + `✂️ Digest too long for Telegram — dropped ${repos.length - kept.length} of ${repos.length} repos: ${repos + .slice(kept.length) + .map((r) => r.repo_name) + .join(', ')}` + ) + } + + const chatId = await sendTelegramMessage(assemble(kept, today), 'Trending repos') - return JSON.stringify({ success: true, chat_id: chatId, date: today }) + return JSON.stringify({ success: true, chat_id: chatId, date: today, delivered: kept.map((r) => r.repo_name) }) }, }) From a9857235b371120ab8105e920ce6af2b483c76be Mon Sep 17 00:00:00 2001 From: damengrandom Date: Sat, 1 Aug 2026 19:54:18 +1000 Subject: [PATCH 2/2] refactor(T-34): drop the explanatory comments Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u --- src/agent/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 0da6b10..8389275 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -178,9 +178,7 @@ export class WorkCoordinator { return curateTrending(newRepos, feedback) } - // Step 4: deliver the digest via Telegram. Returns the repo names that actually - // went out — the tool drops trailing repos when the digest is too long, and - // those were never delivered. + // Step 4: deliver the digest via Telegram. Returns the repo names that went out. private static async sendTelegram(repos: CuratedRepo[]): Promise> { logger.info('⚡️ Sending trending digest via Telegram...')