mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
Claims AFTER all subagent children resolve — by then Lane 1B's queue
changes have posted one child_done message per terminal transition into
this job's inbox (complete / failed / dead / cancelled / timeout). The
aggregator reads those, builds a deterministic markdown summary, and
returns it as the handler result.
Not an LLM call in v0.15 — output is reproducible concatenation so
fan-out runs stay comparable. v0.16+ can add an LLM synthesis pass
behind an opt-in flag.
Contract:
- empty children_ids → `(no children)` marker
- missing child_done (shouldn't happen under v0.15 invariants but
possible if a terminal-state path slipped past Lane 1B) → counted as
failed with "no child_done message observed" error
- non-complete outcomes: result is null in the output so no payload
leaks alongside a failure label
- children appear in the order children_ids was supplied
- custom aggregate_prompt_template replaces the markdown header
13 unit tests cover: empty input, all-success, mixed outcomes, result
suppression on failure, missing child_done handling, order preservation,
custom template, progress + log emission, stringified JSONB payload
parsing, non-child_done inbox filtering, legacy-writer outcome fallback,
and internal helper edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.2 KiB
TypeScript
191 lines
7.2 KiB
TypeScript
/**
|
|
* subagent_aggregator handler tests.
|
|
*
|
|
* The handler's contract is:
|
|
* - read child_done messages from the inbox (already posted by Lane 1B's
|
|
* queue changes on every terminal child transition)
|
|
* - render a markdown summary
|
|
* - return {children, summary, markdown}
|
|
*
|
|
* Tests use a synthetic MinionJobContext that serves a scripted inbox,
|
|
* tracks progress/log writes, and records them so assertions can check
|
|
* that the handler does the right bookkeeping.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
subagentAggregatorHandler,
|
|
__testing,
|
|
} from '../src/core/minions/handlers/subagent-aggregator.ts';
|
|
import type { MinionJobContext, ChildDoneMessage, InboxMessage, ChildOutcome } from '../src/core/minions/types.ts';
|
|
|
|
function ctxWithInbox(
|
|
jobId: number,
|
|
data: Record<string, unknown>,
|
|
inbox: ChildDoneMessage[],
|
|
): MinionJobContext & { _progress: unknown[]; _logs: string[] } {
|
|
const progress: unknown[] = [];
|
|
const logs: string[] = [];
|
|
const inboxMessages: InboxMessage[] = inbox.map((payload, i) => ({
|
|
id: i + 1,
|
|
job_id: jobId,
|
|
sender: 'minions',
|
|
payload: payload as unknown,
|
|
sent_at: new Date(),
|
|
read_at: null,
|
|
}));
|
|
const ctx = {
|
|
id: jobId,
|
|
name: 'subagent_aggregator',
|
|
data,
|
|
attempts_made: 0,
|
|
signal: new AbortController().signal,
|
|
shutdownSignal: new AbortController().signal,
|
|
async updateProgress(p: unknown) { progress.push(p); },
|
|
async updateTokens() {},
|
|
async log(m: string | unknown) { logs.push(typeof m === 'string' ? m : JSON.stringify(m)); },
|
|
async isActive() { return true; },
|
|
async readInbox() { return inboxMessages; },
|
|
_progress: progress,
|
|
_logs: logs,
|
|
};
|
|
return ctx as MinionJobContext & { _progress: unknown[]; _logs: string[] };
|
|
}
|
|
|
|
function done(child_id: number, outcome: ChildOutcome, overrides: Partial<ChildDoneMessage> = {}): ChildDoneMessage {
|
|
return {
|
|
type: 'child_done',
|
|
child_id,
|
|
job_name: `child_${child_id}`,
|
|
result: overrides.result !== undefined ? overrides.result : (outcome === 'complete' ? { ok: true } : null),
|
|
outcome,
|
|
error: overrides.error ?? (outcome === 'complete' ? null : `${outcome}`),
|
|
};
|
|
}
|
|
|
|
describe('subagent_aggregator happy paths', () => {
|
|
test('empty children_ids returns no-children marker', async () => {
|
|
const ctx = ctxWithInbox(1, {}, []);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children).toEqual([]);
|
|
expect(res.markdown).toContain('_(no children)_');
|
|
});
|
|
|
|
test('all children succeed → complete count + bracketed results', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [10, 11] }, [
|
|
done(10, 'complete', { result: { finding: 'a' } }),
|
|
done(11, 'complete', { result: { finding: 'b' } }),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children.length).toBe(2);
|
|
expect(res.summary.complete).toBe(2);
|
|
expect(res.summary.failed).toBe(0);
|
|
expect(res.markdown).toContain('## child 10');
|
|
expect(res.markdown).toContain('"finding": "a"');
|
|
});
|
|
|
|
test('mixed outcomes tallied correctly', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [1, 2, 3, 4] }, [
|
|
done(1, 'complete'),
|
|
done(2, 'failed', { error: 'boom' }),
|
|
done(3, 'cancelled'),
|
|
done(4, 'timeout'),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary).toEqual({ complete: 1, failed: 1, dead: 0, cancelled: 1, timeout: 1 });
|
|
expect(res.markdown).toContain('child 2');
|
|
expect(res.markdown).toContain('error: boom');
|
|
});
|
|
|
|
test('result is null for non-complete outcomes (no leaked payload)', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [42] }, [
|
|
done(42, 'failed', { result: 'should-be-suppressed', error: 'x' }),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children[0]!.result).toBeNull();
|
|
});
|
|
|
|
test('missing child_done is counted as failed with clear error', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [10, 11] }, [done(10, 'complete')]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
const missing = res.children.find(c => c.child_id === 11);
|
|
expect(missing?.outcome).toBe('failed');
|
|
expect(missing?.error).toContain('no child_done message observed');
|
|
expect(res.summary.failed).toBe(1);
|
|
});
|
|
|
|
test('preserves children_ids order in the output', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [3, 1, 2] }, [
|
|
done(1, 'complete'),
|
|
done(2, 'complete'),
|
|
done(3, 'complete'),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children.map(c => c.child_id)).toEqual([3, 1, 2]);
|
|
});
|
|
|
|
test('custom aggregate_prompt_template becomes the markdown header', async () => {
|
|
const ctx = ctxWithInbox(1, {
|
|
children_ids: [1],
|
|
aggregate_prompt_template: '# My synthesis of the shard runs',
|
|
}, [done(1, 'complete')]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.markdown.startsWith('# My synthesis of the shard runs')).toBe(true);
|
|
});
|
|
|
|
test('updateProgress + log emit once per run', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [1, 2] }, [done(1, 'complete'), done(2, 'complete')]);
|
|
await subagentAggregatorHandler(ctx);
|
|
expect(ctx._progress.length).toBe(1);
|
|
expect(ctx._logs.length).toBe(1);
|
|
expect(ctx._logs[0]).toContain('aggregated 2 children');
|
|
});
|
|
});
|
|
|
|
describe('subagent_aggregator payload parsing', () => {
|
|
test('handles stringified child_done payloads (from JSONB fetch)', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [5] }, [
|
|
// Simulate a stringified payload (PG returns JSONB as string in some paths).
|
|
JSON.parse(JSON.stringify(done(5, 'complete'))) as ChildDoneMessage,
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary.complete).toBe(1);
|
|
});
|
|
|
|
test('ignores non-child_done inbox messages', async () => {
|
|
const inboxHybrid = [
|
|
done(1, 'complete'),
|
|
// unrelated payload (e.g. from a future message type)
|
|
{ type: 'ping', echo: 'nope' } as unknown as ChildDoneMessage,
|
|
];
|
|
const ctx = ctxWithInbox(1, { children_ids: [1] }, inboxHybrid);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary.complete).toBe(1);
|
|
});
|
|
|
|
test('falls back to complete when outcome field is absent (legacy writer)', async () => {
|
|
const legacy: ChildDoneMessage = {
|
|
type: 'child_done', child_id: 99, job_name: 'legacy', result: { ok: true },
|
|
};
|
|
const ctx = ctxWithInbox(1, { children_ids: [99] }, [legacy]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children[0]!.outcome).toBe('complete');
|
|
});
|
|
});
|
|
|
|
describe('internal helpers', () => {
|
|
test('formatSummary skips zero counts', () => {
|
|
const s = __testing.emptySummary();
|
|
s.complete = 3;
|
|
s.failed = 1;
|
|
expect(__testing.formatSummary(s)).toBe('complete=3, failed=1');
|
|
});
|
|
|
|
test('parseChildDone rejects obviously-bogus payloads', () => {
|
|
expect(__testing.parseChildDone(null)).toBeNull();
|
|
expect(__testing.parseChildDone({ type: 'not_child_done' })).toBeNull();
|
|
expect(__testing.parseChildDone({ type: 'child_done' })).toBeNull(); // missing child_id
|
|
expect(__testing.parseChildDone('not json')).toBeNull();
|
|
});
|
|
});
|