Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/agent/kpi-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ import { toKpiRecord } from './kpi-record.ts'

const NOW = '2026-07-31T00:00:00.000Z'

// The real tool hardcodes summary: '' and the agent is told not to touch it, so
// this is what every GitHub-only day actually looks like.
test('describes the activity when the agent supplied no summary', () => {
const record = toKpiRecord(JSON.stringify({ summary: '', commits: [1, 2, 3, 4], pullRequests: [1, 2, 3, 4, 5, 6] }), NOW)

assert.equal(record.github_summary, '4 commits, 6 PRs on GitHub')
})

test('singularises a one-commit, one-PR day', () => {
const record = toKpiRecord(JSON.stringify({ commits: [1], pullRequests: [1] }), NOW)

assert.equal(record.github_summary, '1 commit, 1 PR on GitHub')
})

// A genuine quiet day still reads as a day, not as a missing record.
test('describes a zero-activity day rather than leaving it blank', () => {
const record = toKpiRecord(JSON.stringify({ commits: [], pullRequests: [] }), NOW)

assert.equal(record.github_summary, '0 commits, 0 PRs on GitHub')
})

test('maps a complete GitHub payload', () => {
const record = toKpiRecord(JSON.stringify({ summary: 'shipped the thing', commits: [1, 2, 3], pullRequests: [1] }), NOW)

Expand Down
16 changes: 13 additions & 3 deletions src/agent/kpi-record.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { parseJson } from './utils.ts'
import type { KpiRecord } from '../schemas/index.ts'

const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`

function describeActivity(commits: number, prs: number): string {
return `${plural(commits, 'commit')}, ${plural(prs, 'PR')} on GitHub`
}

export function toKpiRecord(githubOutput: string, now: string): KpiRecord {
const data = parseJson<{ summary?: string; commits?: unknown[]; pullRequests?: unknown[] }>(githubOutput, {})
const isDigest = Array.isArray(data.commits) || Array.isArray(data.pullRequests)
const commits_count = data.commits?.length ?? 0
const prs_count = data.pullRequests?.length ?? 0
const github_summary = data.summary?.trim() || (isDigest ? describeActivity(commits_count, prs_count) : '')

return {
github_summary: data.summary ?? '',
commits_count: data.commits?.length ?? 0,
prs_count: data.pullRequests?.length ?? 0,
github_summary,
commits_count,
prs_count,
activities: [],
created_at: now,
updated_at: now,
Expand Down
83 changes: 83 additions & 0 deletions src/agent/notify-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { notifyError } from './utils.ts'

const realFetch = globalThis.fetch
let sent: { text: string; parse_mode: string } | null = null
let status = 200

beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = 'test-token'
process.env.TELEGRAM_CHAT_ID = '12345'
sent = null
status = 200
globalThis.fetch = (async (_url: unknown, init: { body: string }) => {
sent = JSON.parse(init.body)
return new Response(JSON.stringify({ ok: status === 200 }), { status, headers: { 'content-type': 'application/json' } })
}) as unknown as typeof fetch
})

afterEach(() => {
globalThis.fetch = realFetch
})

// Telegram's HTML mode rejects any tag outside its whitelist with a 400, and the
// alert is then never delivered. Real errors carry markup: gateway 502 bodies are
// HTML, and reasoning models emit <think>.
test('escapes markup in the error so Telegram can parse the alert', async () => {
await notifyError('AI news search', new Error('Tavily API error 502: <html><body>upstream connect error</body></html>'))

assert.ok(sent)
assert.equal(sent!.parse_mode, 'HTML')
assert.ok(!sent!.text.includes('<html>'), 'raw <html> would 400')
assert.ok(sent!.text.includes('&lt;html&gt;'), 'the error text should survive, escaped')
// The alert's own formatting must stay intact.
assert.ok(sent!.text.includes('<b>Error:</b>'))
})

test('escapes markup in the context too', async () => {
await notifyError('<script>', new Error('boom'))

assert.ok(!sent!.text.includes('<script>'))
})

// The alert is a summary, so it points at the full trace rather than carrying it.
test('links to LangSmith for the full error', async () => {
await notifyError('AI news search', new Error('boom'))

assert.ok(sent!.text.includes('https://smith.langchain.com'))
})

// Escaping expands the text, so the raw cut has to leave room for the worst case.
test('keeps an oversized error inside Telegram limit', async () => {
await notifyError('AI news search', new Error('"'.repeat(5000)))

assert.ok(sent!.text.length <= 4096, `message was ${sent!.text.length} chars`)
})

// Cutting an escaped string can land mid-entity ("&qu"), which Telegram rejects.
// Cutting first and escaping after makes that impossible.
test('never emits a half-escaped entity', async () => {
await notifyError('AI news search', new Error('"'.repeat(5000)))

const errorLine = sent!.text.split('\n').find((l) => l.startsWith('<b>Error:</b>'))!

assert.ok(!/&[a-z]*$/i.test(errorLine), `error line ended with a partial entity: ${errorLine.slice(-10)}`)
})

test('logs rather than silently dropping a rejected alert', async () => {
status = 400

const errors: unknown[] = []
const { logger } = await import('../utils/logger.ts')
const original = logger.error.bind(logger)
logger.error = ((...args: unknown[]) => errors.push(args)) as typeof logger.error

try {
await notifyError('AI news search', new Error('boom'))
} finally {
logger.error = original
}

assert.equal(errors.length, 1, 'a rejected alert must leave a trace')
})
22 changes: 19 additions & 3 deletions src/agent/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { logger } from '../utils/logger.js'
import { AgentResult } from '../schemas/index.ts'
import { LANGSMITH_URL } from '../constants/index.js'

export function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
Expand Down Expand Up @@ -33,6 +34,12 @@ export function parseJson<T>(raw: string, fallback: T): T {
}
}

function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

const ALERT_ERROR_MAX = 600

export async function notifyError(context: string, error: unknown): Promise<void> {
const token = process.env.TELEGRAM_BOT_TOKEN
const chatId = process.env.TELEGRAM_CHAT_ID
Expand All @@ -42,16 +49,25 @@ export async function notifyError(context: string, error: unknown): Promise<void
const message = [
`⚠️ <b>Oh My Workers — Job Failed</b>`,
``,
`<b>Where:</b> ${context}`,
`<b>Error:</b> ${error instanceof Error ? error.message : String(error)}`,
`<b>Where:</b> ${escapeHtml(context)}`,
`<b>Error:</b> ${escapeHtml(truncate(errorText(error), ALERT_ERROR_MAX))}`,
``,
`🔍 <a href="${LANGSMITH_URL}">Check the error details via LangSmith</a>`,
].join('\n')

try {
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text: message, parse_mode: 'HTML' }),
})

if (!response.ok) {
logger.error(
{ status: response.status, body: await response.text(), context },
'Telegram rejected the failure alert, please check LangSmith for error details'
)
}
} catch (error) {
logger.error({ err: error }, 'Failed to notify error to Telegram')
}
Expand Down
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const LLM_FALLBACK_MODELS = ['nvidia/nemotron-3-super-120b-a12b:free']
// How many repos make the digest, ranked by stars gained today.
export const TRENDING_TOP_N = 8
export const TELEGRAM_MAX_CHARS = 4096
export const LANGSMITH_URL = 'https://smith.langchain.com'
export const TRENDING_SUMMARY_MAX = 140
export const TRENDING_TAGS_MAX = 5
export const TRENDING_TAG_MAX = 24
Expand Down
Loading