mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(cycle): budget the patterns subagent from remaining job time, not a fixed constant (#2781) The autopilot-cycle job gets an interval-derived timeout stamped at submit, but the patterns phase submits its subagent with a fixed 30-min job timeout and waits up to 35 min — one phase's worst case exceeds ANY interval-derived budget <= 35 min, so the parent job dead-letters mid-patterns and the tail phases (consolidate -> schema-suggest) starve for days (#2781, the deeper half left open by the #2852 dispatch-floor fix). - MinionJobContext.deadlineAtMs: absolute deadline from the claim-time timeout_at stamp (the DB ground truth handleTimeouts() sweeps against; re-stamped on every claim so retries get a fresh budget). Null when the job has no per-job timeout. - worker: the per-job abort timer now derives its delay from timeout_at when present, so the in-process timer, the DB sweeper, and the handler-visible deadline agree on ONE absolute instant. - autopilot-cycle + autopilot-global-maintenance handlers thread deadlineAtMs into runCycle; CycleOpts carries it to the patterns phase. - patterns: clampSubagentBudgets() derives BOTH the child job timeout and the wait timeout from the same child deadline (parent deadline minus a 60s stop-margin reserve — enough for the wait poll + force-evict grace + cleanup, deliberately NOT a promise that tail phases complete). Under a 2-min minimum the phase skips honestly (insufficient_cycle_budget) instead of submitting a guaranteed-kill LLM call; the next cycle retries with a fresh budget. - Direct callers (gbrain dream) pass no deadline and keep the configured timeouts unchanged. Follow-up (separate PR): synthesize has the same shape plus sequential per-child waits that accumulate N x subagent_wait_timeout_ms past any parent budget; it needs per-wait remaining-time recomputation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF * fix(cycle): address review — cancel timed-out patterns child; thread deadline through phase-wrapper handlers - P1: the child's timeout_ms clock starts at ITS claim, so a queued child could outlive the parent deadline the wait was clamped to. On wait timeout, cancelJob strips it (waiting -> cancelled; active -> lock stripped, worker abort fires next renew tick). - P2: makePhaseHandler (standalone patterns/synthesize/... minion jobs) now threads job.deadlineAtMs into runCycle like the autopilot handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
712 lines
29 KiB
TypeScript
712 lines
29 KiB
TypeScript
/**
|
|
* Subagent handler tests with a mocked Anthropic Messages client.
|
|
*
|
|
* Strategy: every test scripts a sequence of Messages API responses, hands
|
|
* them to a FakeMessagesClient, and inspects (a) the SubagentResult the
|
|
* handler returns and (b) the persisted rows in subagent_messages +
|
|
* subagent_tool_executions. Replay tests simulate a crash by constructing
|
|
* a fresh handler bound to the same job row with partial state already
|
|
* written.
|
|
*
|
|
* PGLite in-memory so the schema, ON CONFLICT, and two-phase persistence
|
|
* all exercise real SQL.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { MinionQueue } from '../src/core/minions/queue.ts';
|
|
import {
|
|
makeSubagentHandler,
|
|
RateLeaseUnavailableError,
|
|
stripProviderPrefix,
|
|
type MessagesClient,
|
|
} from '../src/core/minions/handlers/subagent.ts';
|
|
import type { ToolDef, MinionJobContext } from '../src/core/minions/types.ts';
|
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
|
|
let engine: PGLiteEngine;
|
|
let queue: MinionQueue;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
queue = new MinionQueue(engine);
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM subagent_tool_executions');
|
|
await engine.executeRaw('DELETE FROM subagent_messages');
|
|
await engine.executeRaw('DELETE FROM subagent_rate_leases');
|
|
await engine.executeRaw('DELETE FROM minion_jobs');
|
|
});
|
|
|
|
// ── FakeMessagesClient ──────────────────────────────────────
|
|
|
|
type FakeResponse = Partial<Anthropic.Message> & { content: Anthropic.Message['content'] };
|
|
|
|
class FakeMessagesClient implements MessagesClient {
|
|
public calls: Anthropic.MessageCreateParamsNonStreaming[] = [];
|
|
constructor(private responses: FakeResponse[]) {}
|
|
async create(
|
|
params: Anthropic.MessageCreateParamsNonStreaming,
|
|
): Promise<Anthropic.Message> {
|
|
this.calls.push(params);
|
|
if (this.responses.length === 0) throw new Error('FakeMessagesClient: out of scripted responses');
|
|
const r = this.responses.shift()!;
|
|
return {
|
|
id: `msg_${this.calls.length}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: params.model,
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } as any,
|
|
...r,
|
|
} as Anthropic.Message;
|
|
}
|
|
}
|
|
|
|
// Build a synthetic MinionJobContext around a real minion_jobs row. The
|
|
// handler only reads data/id/signal/shutdownSignal/updateTokens — we stub
|
|
// the rest. `subagent` is a protected job name (Lane 4H) so tests submit
|
|
// under the trusted-submit flag.
|
|
async function makeCtx(input: unknown): Promise<MinionJobContext> {
|
|
const job = await queue.add(
|
|
'subagent',
|
|
input as Record<string, unknown>,
|
|
{},
|
|
{ allowProtectedSubmit: true },
|
|
);
|
|
const ac = new AbortController();
|
|
const shutdown = new AbortController();
|
|
return {
|
|
id: job.id,
|
|
name: job.name,
|
|
data: (input as Record<string, unknown>) ?? {},
|
|
attempts_made: 0,
|
|
signal: ac.signal,
|
|
deadlineAtMs: null,
|
|
shutdownSignal: shutdown.signal,
|
|
async updateProgress() {},
|
|
async updateTokens() {},
|
|
async log() {},
|
|
async isActive() { return true; },
|
|
async readInbox() { return []; },
|
|
};
|
|
}
|
|
|
|
// ── Tiny tool registry for tests ────────────────────────────
|
|
|
|
function makeEchoTool(name = 'echo', idempotent = true): ToolDef {
|
|
return {
|
|
name,
|
|
description: 'echo input',
|
|
input_schema: { type: 'object', properties: { value: { type: 'string' } }, required: [] },
|
|
idempotent,
|
|
async execute(input) { return { echoed: input }; },
|
|
};
|
|
}
|
|
|
|
function makeThrowingTool(name = 'broken'): ToolDef {
|
|
return {
|
|
name,
|
|
description: 'always throws',
|
|
input_schema: { type: 'object', properties: {}, required: [] },
|
|
idempotent: true,
|
|
async execute() { throw new Error('tool broken'); },
|
|
};
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────
|
|
|
|
describe('subagent handler happy path', () => {
|
|
test('no-tool end_turn: returns text response + persists user + assistant rows', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'hello world' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'hi' });
|
|
|
|
const result = await handler(ctx);
|
|
|
|
expect(result.result).toBe('hello world');
|
|
expect(result.turns_count).toBe(1);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.tokens.in).toBe(10);
|
|
expect(result.tokens.out).toBe(5);
|
|
|
|
const msgs = await engine.executeRaw<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM subagent_messages WHERE job_id = $1`,
|
|
[ctx.id],
|
|
);
|
|
expect(parseInt(msgs[0]!.count, 10)).toBe(2); // user seed + assistant
|
|
});
|
|
|
|
test('single tool_use turn: tool executes, two-phase row goes complete', async () => {
|
|
const tool = makeEchoTool();
|
|
const client = new FakeMessagesClient([
|
|
{
|
|
content: [
|
|
{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { value: 'v1' } } as any,
|
|
],
|
|
stop_reason: 'tool_use' as any,
|
|
},
|
|
{
|
|
content: [{ type: 'text', text: 'done' }] as any,
|
|
stop_reason: 'end_turn',
|
|
},
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'go' });
|
|
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('done');
|
|
expect(client.calls.length).toBe(2);
|
|
|
|
// tool_executions row complete with echoed output
|
|
const rows = await engine.executeRaw<{ status: string; output: unknown }>(
|
|
`SELECT status, output FROM subagent_tool_executions WHERE job_id = $1`,
|
|
[ctx.id],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0]!.status).toBe('complete');
|
|
const out = typeof rows[0]!.output === 'string' ? JSON.parse(rows[0]!.output as string) : rows[0]!.output;
|
|
expect(out).toEqual({ echoed: { value: 'v1' } });
|
|
});
|
|
|
|
test('tool throws: row goes failed, model sees error, loop continues', async () => {
|
|
const tool = makeThrowingTool();
|
|
const client = new FakeMessagesClient([
|
|
{
|
|
content: [{ type: 'tool_use', id: 'tu_1', name: 'broken', input: {} } as any],
|
|
stop_reason: 'tool_use' as any,
|
|
},
|
|
{
|
|
content: [{ type: 'text', text: 'recovered' }] as any,
|
|
stop_reason: 'end_turn',
|
|
},
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'try' });
|
|
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('recovered');
|
|
|
|
const rows = await engine.executeRaw<{ status: string; error: string | null }>(
|
|
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
|
|
[ctx.id],
|
|
);
|
|
expect(rows[0]!.status).toBe('failed');
|
|
expect(rows[0]!.error).toContain('tool broken');
|
|
});
|
|
|
|
test('unknown tool name fails execution but loop continues', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{
|
|
content: [{ type: 'tool_use', id: 'tu_nope', name: 'no_such_tool', input: {} } as any],
|
|
stop_reason: 'tool_use' as any,
|
|
},
|
|
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'x' });
|
|
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
|
|
const rows = await engine.executeRaw<{ status: string; error: string | null }>(
|
|
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
|
|
[ctx.id],
|
|
);
|
|
expect(rows[0]!.status).toBe('failed');
|
|
expect(rows[0]!.error).toContain('not in the registry');
|
|
});
|
|
|
|
test('max_turns exceeded returns stop_reason=max_turns', async () => {
|
|
// Model keeps calling tool_use forever; we cap at 2 turns.
|
|
const echoing: FakeResponse[] = Array.from({ length: 5 }).map((_, i) => ({
|
|
content: [{ type: 'tool_use', id: `tu_${i}`, name: 'echo', input: {} } as any],
|
|
stop_reason: 'tool_use' as any,
|
|
}));
|
|
const client = new FakeMessagesClient(echoing);
|
|
const tool = makeEchoTool();
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'loop', max_turns: 2 });
|
|
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('max_turns');
|
|
expect(result.turns_count).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('subagent handler replay (crash recovery)', () => {
|
|
test('resumes from persisted messages when prior rows exist', async () => {
|
|
// Seed an in-progress conversation by running the first client, then
|
|
// running a second handler on the SAME job with responses starting at
|
|
// turn 2. No duplicate user-seed row (ON CONFLICT DO NOTHING).
|
|
const tool = makeEchoTool();
|
|
const client1 = new FakeMessagesClient([
|
|
{
|
|
content: [{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { v: 1 } } as any],
|
|
stop_reason: 'tool_use' as any,
|
|
},
|
|
]);
|
|
const handler1 = makeSubagentHandler({ engine, client: client1, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'start' });
|
|
|
|
// Run handler1 until it WOULD make a second LLM call — force that
|
|
// second call to error so we persist only the first assistant message.
|
|
try {
|
|
const client1b = new FakeMessagesClient([
|
|
{
|
|
content: [{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { v: 1 } } as any],
|
|
stop_reason: 'tool_use' as any,
|
|
},
|
|
]);
|
|
const interrupted = makeSubagentHandler({ engine, client: client1b, toolRegistry: [tool] });
|
|
await interrupted(ctx);
|
|
} catch {
|
|
// Out-of-scripted-responses — simulates worker kill before turn 2.
|
|
}
|
|
|
|
// Confirm partial state: 1 user + 1 assistant + 1 synthesized user
|
|
// (tool_result) + 1 tool_exec complete.
|
|
const preRows = await engine.executeRaw<{ c: string }>(
|
|
`SELECT count(*)::text AS c FROM subagent_messages WHERE job_id = $1`,
|
|
[ctx.id],
|
|
);
|
|
const preCount = parseInt(preRows[0]!.c, 10);
|
|
expect(preCount).toBeGreaterThanOrEqual(1);
|
|
|
|
// Resume with a fresh handler + client that supplies ONE more response.
|
|
const client2 = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'resumed ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler2 = makeSubagentHandler({ engine, client: client2, toolRegistry: [tool] });
|
|
const result = await handler2(ctx);
|
|
|
|
expect(result.result).toBe('resumed ok');
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
// Second client should see the prior conversation in the messages
|
|
// array — at minimum the user seed + prior assistant + tool_result.
|
|
expect(client2.calls[0]!.messages.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
test('prior completed tool exec is replayed without re-invoking execute', async () => {
|
|
// Prior state: a completed tool row. We assert the tool's execute is
|
|
// NOT called on resume. Use a tool that throws if invoked — passing
|
|
// means we used the replay path.
|
|
const throwingTool = makeThrowingTool('pre_done');
|
|
const ctx = await makeCtx({ prompt: 'start' });
|
|
|
|
// Seed prior state manually: user, assistant with tool_use, tool_exec complete.
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
|
|
VALUES ($1, 0, 'user', $2::jsonb)`,
|
|
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, model)
|
|
VALUES ($1, 1, 'assistant', $2::jsonb, 'claude-sonnet-4-6')`,
|
|
[
|
|
ctx.id,
|
|
JSON.stringify([
|
|
{ type: 'tool_use', id: 'tu_seeded', name: 'pre_done', input: {} },
|
|
]),
|
|
],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_tool_executions (job_id, message_idx, tool_use_id, tool_name, input, status, output)
|
|
VALUES ($1, 1, 'tu_seeded', 'pre_done', '{}'::jsonb, 'complete', $2::jsonb)`,
|
|
[ctx.id, JSON.stringify({ replayed: true })],
|
|
);
|
|
|
|
// Handler MUST NOT call the throwing execute and MUST end the loop on
|
|
// the next LLM response.
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'finished after replay' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [throwingTool] });
|
|
const result = await handler(ctx);
|
|
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('finished after replay');
|
|
// Only one LLM call made on this resume (we had 2 persisted messages +
|
|
// the tool result synthesis happened when resuming, then model spoke).
|
|
expect(client.calls.length).toBe(1);
|
|
});
|
|
|
|
// v0.37.7.0 #1151 regression — terminal-on-resume.
|
|
// Pre-fix, this scenario dead-lettered the job: replay reconciler saw
|
|
// last=assistant with zero tool_uses, did nothing, main loop called
|
|
// messages.create against a conversation ending in assistant → Sonnet
|
|
// 4.6+ rejects assistant-prefill with HTTP 400 → 3 retries → dead.
|
|
// Post-fix, the reconciler short-circuits: reconstructs finalText from
|
|
// the persisted text blocks and returns stop_reason='end_turn' without
|
|
// any LLM call.
|
|
test('text-only assistant tail on resume returns terminal without LLM call (#1151)', async () => {
|
|
const ctx = await makeCtx({ prompt: 'start' });
|
|
// Seed prior state: user prompt, then a TERMINAL assistant turn
|
|
// (text-only, no tool_use blocks). This is the exact shape the
|
|
// #1151 reporter found in their dead jobs (job 190's last message
|
|
// was a synthesis summary listing 3 written slugs).
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
|
|
VALUES ($1, 0, 'user', $2::jsonb)`,
|
|
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, model, tokens_in, tokens_out)
|
|
VALUES ($1, 1, 'assistant', $2::jsonb, 'claude-sonnet-4-6', 100, 50)`,
|
|
[
|
|
ctx.id,
|
|
JSON.stringify([
|
|
{ type: 'text', text: 'wrote 3 pages: wiki/notes/a, wiki/notes/b, wiki/notes/c' },
|
|
]),
|
|
],
|
|
);
|
|
|
|
// The FakeMessagesClient has ZERO scripted responses. If the handler
|
|
// tries to call messages.create, it throws. The fix guarantees we
|
|
// never reach that path.
|
|
const client = new FakeMessagesClient([]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const result = await handler(ctx);
|
|
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('wrote 3 pages: wiki/notes/a, wiki/notes/b, wiki/notes/c');
|
|
// Crucial assertion: no messages.create call was made on resume.
|
|
expect(client.calls.length).toBe(0);
|
|
// Token totals from the persisted assistant message rolled up.
|
|
expect(result.tokens.in).toBe(100);
|
|
expect(result.tokens.out).toBe(50);
|
|
});
|
|
|
|
// Companion: the existing tool-use replay path is unchanged.
|
|
test('text-only terminal short-circuit does NOT affect tool-use replay path', async () => {
|
|
// This is a smoke test that the new else-branch doesn't accidentally
|
|
// swallow the pending-tool-use case. If we have a persisted assistant
|
|
// with a tool_use block (no synthesized user turn yet), the existing
|
|
// tool-synthesis path must still fire.
|
|
const echoTool = makeEchoTool('echo_x');
|
|
const ctx = await makeCtx({ prompt: 'start' });
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
|
|
VALUES ($1, 0, 'user', $2::jsonb)`,
|
|
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, model)
|
|
VALUES ($1, 1, 'assistant', $2::jsonb, 'claude-sonnet-4-6')`,
|
|
[
|
|
ctx.id,
|
|
JSON.stringify([
|
|
{ type: 'tool_use', id: 'tu_pending', name: 'echo_x', input: { v: 'r' } },
|
|
]),
|
|
],
|
|
);
|
|
// No prior tool_exec row — replay reconciler will dispatch.
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'done after tool' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [echoTool] });
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('done after tool');
|
|
// The handler DID call messages.create (one call) after synthesizing
|
|
// the tool_result wrapper.
|
|
expect(client.calls.length).toBe(1);
|
|
});
|
|
|
|
test('pending non-idempotent tool exec rejects on resume', async () => {
|
|
const nonIdempotent = { ...makeEchoTool('do_once'), idempotent: false };
|
|
const ctx = await makeCtx({ prompt: 'start' });
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
|
|
VALUES ($1, 0, 'user', $2::jsonb)`,
|
|
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
|
|
VALUES ($1, 1, 'assistant', $2::jsonb)`,
|
|
[
|
|
ctx.id,
|
|
JSON.stringify([{ type: 'tool_use', id: 'tu_x', name: 'do_once', input: {} }]),
|
|
],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_tool_executions (job_id, message_idx, tool_use_id, tool_name, input, status)
|
|
VALUES ($1, 1, 'tu_x', 'do_once', '{}'::jsonb, 'pending')`,
|
|
[ctx.id],
|
|
);
|
|
|
|
const client = new FakeMessagesClient([]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [nonIdempotent] });
|
|
await expect(handler(ctx)).rejects.toThrow(/non-idempotent/);
|
|
});
|
|
});
|
|
|
|
describe('subagent handler lease behavior', () => {
|
|
test('acquires + releases a lease around the LLM call', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({
|
|
engine, client, toolRegistry: [], maxConcurrent: 1, rateLeaseKey: 'k1',
|
|
});
|
|
const ctx = await makeCtx({ prompt: 'hi' });
|
|
await handler(ctx);
|
|
// No leases should remain after completion.
|
|
const rows = await engine.executeRaw<{ c: string }>(
|
|
`SELECT count(*)::text AS c FROM subagent_rate_leases`,
|
|
);
|
|
expect(parseInt(rows[0]!.c, 10)).toBe(0);
|
|
});
|
|
|
|
test('v0.41 Bug 3: stripProviderPrefix strips `anthropic:` qualified model', async () => {
|
|
expect(stripProviderPrefix('anthropic:claude-sonnet-4-6')).toBe('claude-sonnet-4-6');
|
|
});
|
|
|
|
test('v0.41 Bug 3: stripProviderPrefix is idempotent on bare names', async () => {
|
|
expect(stripProviderPrefix('claude-sonnet-4-6')).toBe('claude-sonnet-4-6');
|
|
});
|
|
|
|
test('v0.41 Bug 3: stripProviderPrefix handles edge inputs', async () => {
|
|
expect(stripProviderPrefix('')).toBe('');
|
|
// Leading colon = no valid provider name; pass through unchanged.
|
|
// The `idx > 0` guard (not `>= 0`) makes this intentional.
|
|
expect(stripProviderPrefix(':')).toBe(':');
|
|
expect(stripProviderPrefix('a:b:c')).toBe('b:c'); // only strips first prefix
|
|
});
|
|
|
|
test('v0.41 Bug 3: handler passes bare model id to Anthropic SDK when data.model is qualified', async () => {
|
|
const calls: Array<Anthropic.MessageCreateParamsNonStreaming> = [];
|
|
const client: MessagesClient = {
|
|
async create(params) {
|
|
calls.push(params);
|
|
return {
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 1, output_tokens: 1 },
|
|
role: 'assistant',
|
|
} as unknown as Anthropic.Message;
|
|
},
|
|
};
|
|
const handler = makeSubagentHandler({
|
|
engine, client, toolRegistry: [], maxConcurrent: 100, rateLeaseKey: 'k_prefix',
|
|
});
|
|
const ctx = await makeCtx({
|
|
prompt: 'hello',
|
|
model: 'anthropic:claude-sonnet-4-6', // qualified — the field-report bug case
|
|
});
|
|
await handler(ctx);
|
|
expect(calls.length).toBe(1);
|
|
// The SDK MUST receive the bare model id, not the prefixed one.
|
|
expect(calls[0]!.model).toBe('claude-sonnet-4-6');
|
|
});
|
|
|
|
test('throws RateLeaseUnavailableError when cap full', async () => {
|
|
// Preload the cap with a stale-looking-but-live lease owned by a
|
|
// different job.
|
|
const owner = await queue.add('holder', {});
|
|
await engine.executeRaw(
|
|
`INSERT INTO subagent_rate_leases (key, owner_job_id, expires_at)
|
|
VALUES ('k_cap', $1, now() + interval '1 minute')`,
|
|
[owner.id],
|
|
);
|
|
const client = new FakeMessagesClient([]);
|
|
const handler = makeSubagentHandler({
|
|
engine, client, toolRegistry: [], maxConcurrent: 1, rateLeaseKey: 'k_cap',
|
|
});
|
|
const ctx = await makeCtx({ prompt: 'blocked' });
|
|
await expect(handler(ctx)).rejects.toBeInstanceOf(RateLeaseUnavailableError);
|
|
});
|
|
});
|
|
|
|
describe('subagent handler input validation', () => {
|
|
test('missing prompt throws', async () => {
|
|
const client = new FakeMessagesClient([]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({});
|
|
await expect(handler(ctx)).rejects.toThrow(/prompt/);
|
|
});
|
|
|
|
test('allowed_tools unknown name rejected at dispatch', async () => {
|
|
const tool = makeEchoTool('real');
|
|
const client = new FakeMessagesClient([]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'x', allowed_tools: ['real', 'ghost_tool'] });
|
|
await expect(handler(ctx)).rejects.toThrow(/unknown tool/);
|
|
});
|
|
});
|
|
|
|
describe('makeSubagentHandler default client construction', () => {
|
|
test('factory default wires sdk.messages through to the handler', async () => {
|
|
// Regression guard for the v0.16.0 shipped bug: makeSubagentHandler
|
|
// was casting `new Anthropic()` (top-level SDK class) to MessagesClient,
|
|
// but `.create()` lives at sdk.messages.create. Every subagent job in
|
|
// production died with "client.create is not a function" on first LLM
|
|
// call. This test exercises the default-client path (no `deps.client`
|
|
// injected) via the makeAnthropic dep-injection seam, so the exact
|
|
// default-branch construction is covered without a real API call.
|
|
const calls: Anthropic.MessageCreateParamsNonStreaming[] = [];
|
|
const fakeSdk = {
|
|
messages: {
|
|
async create(
|
|
params: Anthropic.MessageCreateParamsNonStreaming,
|
|
): Promise<Anthropic.Message> {
|
|
calls.push(params);
|
|
return {
|
|
id: 'msg_regression',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: params.model,
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
usage: {
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
},
|
|
} as unknown as Anthropic.Message;
|
|
},
|
|
},
|
|
} as unknown as Anthropic;
|
|
|
|
// Crucial: do NOT pass `client`. Only `makeAnthropic`. This forces the
|
|
// factory to hit the default-client branch (`deps.client ?? makeAnthropic().messages`).
|
|
const handler = makeSubagentHandler({
|
|
engine,
|
|
makeAnthropic: () => fakeSdk,
|
|
toolRegistry: [],
|
|
});
|
|
const ctx = await makeCtx({ prompt: 'hello' });
|
|
const result = await handler(ctx);
|
|
|
|
expect(calls.length).toBe(1);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('ok');
|
|
});
|
|
});
|
|
|
|
// ── #2778: per-turn output-token cap + max_tokens stop handling ─────
|
|
|
|
import { resolveMaxOutputTokens } from '../src/core/minions/handlers/subagent.ts';
|
|
|
|
describe('resolveMaxOutputTokens (#2778)', () => {
|
|
test('defaults to 8192 when nothing set', () => {
|
|
expect(resolveMaxOutputTokens(undefined, null)).toBe(8192);
|
|
expect(resolveMaxOutputTokens(undefined, undefined)).toBe(8192);
|
|
});
|
|
|
|
test('per-job value wins over config', () => {
|
|
expect(resolveMaxOutputTokens(2048, '5000')).toBe(2048);
|
|
});
|
|
|
|
test('config value used when per-job unset', () => {
|
|
expect(resolveMaxOutputTokens(undefined, '5000')).toBe(5000);
|
|
});
|
|
|
|
test('invalid values fall through to next tier', () => {
|
|
expect(resolveMaxOutputTokens(0, '5000')).toBe(5000);
|
|
expect(resolveMaxOutputTokens(-1, null)).toBe(8192);
|
|
expect(resolveMaxOutputTokens(Number.NaN, 'garbage')).toBe(8192);
|
|
expect(resolveMaxOutputTokens(undefined, '')).toBe(8192);
|
|
expect(resolveMaxOutputTokens(undefined, '0')).toBe(8192);
|
|
});
|
|
});
|
|
|
|
describe('subagent handler output-token cap (#2778)', () => {
|
|
test('default: SDK call carries max_tokens=8192 (was hardcoded 4096)', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'hi' });
|
|
await handler(ctx);
|
|
expect(client.calls[0]!.max_tokens).toBe(8192);
|
|
});
|
|
|
|
test('data.max_tokens flows to the SDK call', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'hi', max_tokens: 2048 });
|
|
await handler(ctx);
|
|
expect(client.calls[0]!.max_tokens).toBe(2048);
|
|
});
|
|
|
|
test('agent.max_output_tokens config flows to the SDK call', async () => {
|
|
await engine.setConfig('agent.max_output_tokens', '5000');
|
|
try {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'hi' });
|
|
await handler(ctx);
|
|
expect(client.calls[0]!.max_tokens).toBe(5000);
|
|
} finally {
|
|
await engine.executeRaw(`DELETE FROM config WHERE key = 'agent.max_output_tokens'`);
|
|
}
|
|
});
|
|
|
|
test('final turn hitting the cap surfaces stop_reason=max_tokens, not a silent end_turn', async () => {
|
|
const client = new FakeMessagesClient([
|
|
{ content: [{ type: 'text', text: 'truncated tex' }] as any, stop_reason: 'max_tokens' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
|
|
const ctx = await makeCtx({ prompt: 'hi' });
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('max_tokens');
|
|
expect(result.result).toBe('truncated tex');
|
|
});
|
|
|
|
test('max_tokens stop with tool_use: truncation note injected so the model re-issues the dropped call', async () => {
|
|
const tool = makeEchoTool();
|
|
const client = new FakeMessagesClient([
|
|
{
|
|
// A complete tool_use survived, but the turn stopped on max_tokens —
|
|
// the API dropped whatever came after (e.g. a big put_page call).
|
|
content: [{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { value: 'v1' } } as any],
|
|
stop_reason: 'max_tokens' as any,
|
|
},
|
|
{ content: [{ type: 'text', text: 'recovered' }] as any, stop_reason: 'end_turn' },
|
|
]);
|
|
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
|
|
const ctx = await makeCtx({ prompt: 'go' });
|
|
|
|
const result = await handler(ctx);
|
|
expect(result.stop_reason).toBe('end_turn');
|
|
expect(result.result).toBe('recovered');
|
|
|
|
// The synthesized user turn (persisted + fed to the second call) must
|
|
// carry the truncation note alongside the tool_result. Assert on the
|
|
// persisted row — client.calls[].messages is the live array the loop
|
|
// keeps mutating, so positional checks there are unreliable.
|
|
const rows = await engine.executeRaw<{ content_blocks: unknown }>(
|
|
`SELECT content_blocks FROM subagent_messages
|
|
WHERE job_id = $1 AND role = 'user' AND message_idx > 0
|
|
ORDER BY message_idx ASC`,
|
|
[ctx.id],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
const blocks = (typeof rows[0]!.content_blocks === 'string'
|
|
? JSON.parse(rows[0]!.content_blocks as string)
|
|
: rows[0]!.content_blocks) as Array<{ type: string; text?: string }>;
|
|
expect(blocks.some(b => b.type === 'tool_result')).toBe(true);
|
|
const texts = blocks.filter(b => b.type === 'text').map(b => b.text ?? '');
|
|
expect(texts.some(t => t.includes('truncated') && t.includes('DROPPED'))).toBe(true);
|
|
});
|
|
});
|