/**
 * 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);
  });
});
