diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index 2925862..3cadedd 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -91,6 +91,33 @@ test('well-formed JSON that fails the schema produces a distinguishably differen assert.doesNotMatch(result.error ?? '', /not valid JSON/) }) +const emptyCuratorOutput = JSON.stringify({ repos: [] }) + +test('treats an empty curation as a failure, not a quiet day', async () => { + const fakeCurate = async () => emptyCuratorOutput + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null, 'an empty result must not be reported as success') + assert.match(result.error ?? '', /none of the 1 repos/) +}) + +test('retries an empty curation and tells the model which name to echo', async () => { + let calls = 0 + const feedback: (string | undefined)[] = [] + const fakeCurate = async (_repos: TrendingRepo[], fb?: string) => { + calls++ + feedback.push(fb) + return calls === 1 ? emptyCuratorOutput : validCuratorOutput + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(calls, 2, 'an empty curation should be retried') + assert.equal(result.curated?.[0].repo_name, 'foo/bar') + assert.match(feedback[1] ?? '', /foo\/bar/, 'the retry should name the repo the model failed to match') +}) + test('an exception thrown by curate() is caught and recorded instead of crashing the graph', async () => { const fakeCurate = async () => { throw new Error('rate limited') diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index eb164f1..74a285b 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -43,9 +43,18 @@ async function curateNode(state: { repos: TrendingRepo[]; error: string | null; } const parsed = CuratedRepoOutputSchema.safeParse(json) - if (parsed.success) return { curated: parsed.data.repos, error: null } + if (!parsed.success) { + return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } + } + + if (!parsed.data.repos.length) { + return { + error: `curator returned summaries for none of the ${state.repos.length} repos it was given — repo_name must match exactly, e.g. "${state.repos[0]?.name}"`, + attempts: state.attempts + 1, + } + } - return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } + return { curated: parsed.data.repos, error: null } } export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { diff --git a/src/agent/index.ts b/src/agent/index.ts index 53d8a4d..b7f06fa 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -328,8 +328,7 @@ export class WorkCoordinator { return WorkCoordinator.rankByGrowth(allRepos) } - // Step 3 with its failure handling: null means stop, and the alert has already - // been sent. An empty result is a skip, not a failure — nothing to alert on. + // Step 3 with its failure handling: null means stop, and the alert has already been sent. private static async curateOrNotify(topRepos: TrendingRepo[]): Promise { const { curated, error } = await runCuratorGraph(topRepos, WorkCoordinator.curateRepos) @@ -341,11 +340,6 @@ export class WorkCoordinator { return null } - if (!curated.length) { - logger.info('⏭️ No repos curated — skipping send and save.') - return null - } - return curated }