diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index 6e511ad..2925862 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -59,3 +59,58 @@ test('gives up after exhausting retries — curated is null, not an empty array, assert.equal(result.curated, null) assert.ok(result.error, 'expected a validation error to be surfaced, not swallowed') }) + +test('non-JSON output names the parse failure and includes the offending text, not a generic Zod null error', async () => { + const fakeCurate = async () => 'this is not json at all' + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /not valid JSON/) + assert.match(result.error ?? '', /this is not json at all/) +}) + +test('a long non-JSON response is truncated in the recorded error, with the original length reported', async () => { + const longRaw = 'x'.repeat(500) + const fakeCurate = async () => longRaw + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /500 chars/) + assert.ok((result.error ?? '').length < longRaw.length, 'expected the excerpt to be bounded, not a full raw dump') +}) + +test('well-formed JSON that fails the schema produces a distinguishably different error from a parse failure', async () => { + const fakeCurate = async () => JSON.stringify({ repos: [{ nope: true }] }) + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /did not match the expected schema/) + assert.doesNotMatch(result.error ?? '', /not valid JSON/) +}) + +test('an exception thrown by curate() is caught and recorded instead of crashing the graph', async () => { + const fakeCurate = async () => { + throw new Error('rate limited') + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /curate\(\) threw/) + assert.match(result.error ?? '', /rate limited/) +}) + +test('a non-Error value thrown by curate() is still stringified into the recorded error', async () => { + const fakeCurate = async () => { + throw 'connection reset' + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /curate\(\) threw/) + assert.match(result.error ?? '', /connection reset/) +}) diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index c83d772..eb164f1 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -2,7 +2,7 @@ import { StateGraph, StateSchema, START, END } from '@langchain/langgraph' import { z } from 'zod' import { TrendingRepoOutputSchema, type CuratedRepo } from '../schemas/index.js' -import { parseJson } from './utils.ts' +import { truncate } from './utils.ts' import { TrendingRepo } from '../schemas/index.ts' const CuratedRepoOutputSchema = z.object({ repos: z.array(TrendingRepoOutputSchema) }) @@ -11,6 +11,7 @@ export type CurateFn = (repos: TrendingRepo[], feedback?: string) => Promise(), @@ -19,16 +20,37 @@ const CuratorState = new StateSchema({ attempts: z.custom(), }) -export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { - const graph = new StateGraph(CuratorState) - .addNode('curate', async (state) => { - const raw = await curate(state.repos, state.error || undefined) - const parsed = CuratedRepoOutputSchema.safeParse(parseJson(raw, null)) +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} - if (parsed.success) return { curated: parsed.data.repos, error: null } +async function curateNode(state: { repos: TrendingRepo[]; error: string | null; attempts: number }, curate: CurateFn) { + let raw: string + try { + raw = await curate(state.repos, state.error || undefined) + } catch (err) { + return { error: `curate() threw: ${errorMessage(err)}`, attempts: state.attempts + 1 } + } + + let json: unknown + try { + json = JSON.parse(raw) + } catch (err) { + return { + error: `curator output was not valid JSON (${errorMessage(err)}) — ${raw.length} chars: ${truncate(raw, EXCERPT_MAX)}`, + attempts: state.attempts + 1, + } + } + + const parsed = CuratedRepoOutputSchema.safeParse(json) + if (parsed.success) return { curated: parsed.data.repos, error: null } + + return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } +} - return { error: parsed.error.message, attempts: state.attempts + 1 } - }) +export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { + const graph = new StateGraph(CuratorState) + .addNode('curate', (state) => curateNode(state, curate)) .addEdge(START, 'curate') .addConditionalEdges('curate', (state) => (state.curated || state.attempts >= MAX_ATTEMPTS ? END : 'curate')) .compile() diff --git a/src/agent/index.ts b/src/agent/index.ts index 1d13607..53d8a4d 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -175,13 +175,7 @@ export class WorkCoordinator { private static async curateRepos(newRepos: TrendingRepo[], feedback?: string): Promise { logger.info('⚡️ Writing digest summaries...') - try { - return await curateTrending(newRepos, feedback) - } catch (err) { - logger.error({ err }, '❌ Trending curation attempt failed') - - return '' - } + return curateTrending(newRepos, feedback) } // Step 4: deliver the digest via Telegram. Returns whether the send succeeded.