import type {
  AIProvider,
  AIProviderRegistry,
  CachePolicy,
  ChatDriverResult,
  ChatMessage,
  ChatRequestOptions,
  ChatToolCall,
  ChatToolChoice,
  ChatToolDefinition,
  InteractionResult,
  TurnFailureReason,
} from '@genesislcap/foundation-ai';
import {
  isChatToolCallUnknown,
  MalformedFunctionCallError,
  ResponseTruncatedError,
} from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { type ActivityBus, AgenticActivityBus } from '../../channel/ai-activity-bus';
import type { AgentConfig } from '../../config/config';
import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
import { sumCosts } from '../../utils/sum-costs';
import { sumTokens } from '../../utils/sum-tokens';
import { createToolFold } from '../../utils/tool-fold';
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
// jsdom's EventTarget rather than Node's native one (see the file). None of the
// imports above pull in the driver, so its realm is still set before evaluation.
import './align-event-globals';
import { ChatDriver } from './chat-driver';

// ---------------------------------------------------------------------------
// Test harness
//
// The driver calls `provider.chat(history, userMessage, options)` once per
// tool-loop iteration and inspects the returned ChatMessage: a message with
// `toolCalls` keeps the loop running; one without ends the turn. So a fake
// provider that replays a scripted sequence of ChatMessages is enough to drive
// any tool-loop path. We capture the advertised tool names per call so tests
// can assert per-state narrowing actually happened.
// ---------------------------------------------------------------------------

interface ScriptedProvider extends AIProvider {
  /** Tool names advertised to the model on each `chat()` call, in order. */
  advertisedPerCall: string[][];
  /** `toolChoice` seen on each `chat()` call, in order (sub-agents force it). */
  toolChoicePerCall: Array<ChatToolChoice | undefined>;
  /** `temperature` seen on each `chat()` call, in order. */
  temperaturePerCall: Array<number | undefined>;
  /** `cachePolicy` seen on each `chat()` call, in order. */
  cachePolicyPerCall: Array<CachePolicy | undefined>;
  /** `tailContext` seen on each `chat()` call, in order (already framed by the driver). */
  tailContextPerCall: Array<string | undefined>;
  /** `systemPrompt` seen on each `chat()` call, in order. */
  systemPromptPerCall: Array<string | undefined>;
  /** The model-bound history slice seen on each `chat()` call (post-transform). */
  historyPerCall: ChatMessage[][];
}

const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
  const queue = [...responses];
  const advertisedPerCall: string[][] = [];
  const toolChoicePerCall: Array<ChatToolChoice | undefined> = [];
  const temperaturePerCall: Array<number | undefined> = [];
  const cachePolicyPerCall: Array<CachePolicy | undefined> = [];
  const tailContextPerCall: Array<string | undefined> = [];
  const systemPromptPerCall: Array<string | undefined> = [];
  const historyPerCall: ChatMessage[][] = [];
  return {
    advertisedPerCall,
    toolChoicePerCall,
    temperaturePerCall,
    cachePolicyPerCall,
    tailContextPerCall,
    systemPromptPerCall,
    historyPerCall,
    chat: async (
      history: ChatMessage[],
      _userMessage: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      advertisedPerCall.push((options?.tools ?? []).map((t) => t.name));
      toolChoicePerCall.push(options?.toolChoice);
      temperaturePerCall.push(options?.temperature);
      cachePolicyPerCall.push(options?.cachePolicy);
      tailContextPerCall.push(options?.tailContext);
      systemPromptPerCall.push(options?.systemPrompt);
      // Snapshot the slice (condensation produces fresh objects per call and never
      // mutates stored history, so a shallow array copy is a stable per-call view).
      historyPerCall.push([...history]);
      // Once the script is exhausted, end the turn with a plain text reply.
      return queue.shift() ?? { role: 'assistant', content: 'done' };
    },
  };
};

const makeRegistry = (provider: AIProvider): AIProviderRegistry => ({
  get: () => provider,
  default: () => provider,
  defaultName: () => 'test',
  names: () => ['test'],
  getStatus: async () => null,
  listStatuses: async () => [],
});

const def = (name: string): ChatToolDefinition => ({
  name,
  description: `${name} tool`,
  parameters: { type: 'object', properties: {} },
});

/** An assistant turn that calls a single tool. `content` is empty so the driver
 * does not treat it as a thinking step (which would split it into two messages). */
const callsTool = (name: string, id: string): ChatMessage => ({
  role: 'assistant',
  content: '',
  toolCalls: [{ id, name, args: {} }],
});

const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
  ({ description: 'test agent', ...overrides }) as AgentConfig;

const makeDriver = (
  config: AgentConfig,
  provider: AIProvider,
  sessionKey = '',
  activityBus?: ActivityBus,
): ChatDriver => {
  const driver = new ChatDriver(makeRegistry(provider), {
    maxToolIterations: 50,
    maxFoldOperations: 5,
    sessionKey,
    activityBus,
  });
  driver.applyAgent(config);
  return driver;
};

/** All tool calls across the whole conversation, flattened. */
const allToolCalls = (driver: ChatDriver): ChatToolCall[] =>
  driver.getHistory().flatMap((m) => m.toolCalls ?? []);

/** Tool-result message contents, in order. */
const toolResultContents = (driver: ChatDriver): string[] =>
  driver
    .getHistory()
    .filter((m) => m.role === 'tool' && m.toolResult)
    .map((m) => m.toolResult!.content);

/** `tool.unresolved` meta-event details recorded for a session (download-log surface). */
const unresolvedEvents = (sessionKey: string): Array<Record<string, unknown>> =>
  getMetaEvents(sessionKey)
    .filter((e) => e.type === 'tool.unresolved')
    .map((e) => e.detail ?? {});

// ---------------------------------------------------------------------------
// stale tool detection — stateful agent advances past a tool's state
// ---------------------------------------------------------------------------

const stale = createLogicSuite('ChatDriver stale-tool detection');

stale('guides the model when it calls a tool that an earlier state exposed', async () => {
  // State A exposes tool_a; calling it advances to state B, which exposes only
  // tool_b. A factory-form agent narrows the tool set per turn, mirroring how
  // `defineStatefulAgent` works.
  let state: 'A' | 'B' = 'A';
  const config = agent({
    name: 'Stateful',
    toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
    toolHandlers: () =>
      state === 'A'
        ? {
            tool_a: async () => {
              state = 'B';
              return 'advanced to B';
            },
          }
        : { tool_b: async () => 'b done' },
  });

  const provider = scriptedProvider([
    callsTool('tool_a', 't1'), // real — advances A -> B
    callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
    callsTool('tool_b', 't3'), // real — valid in state B
  ]);
  const sessionKey = 'stale-meta-test';
  const driver = makeDriver(config, provider, sessionKey);

  const result = await driver.sendMessage('go');
  assert.is(result.reason, 'done');

  // The per-state narrowing actually happened: tool_a advertised first, tool_b later.
  assert.equal(provider.advertisedPerCall[0], ['tool_a']);
  assert.ok(
    provider.advertisedPerCall.some(
      (tools) => tools.includes('tool_b') && !tools.includes('tool_a'),
    ),
    'a later turn should advertise tool_b without tool_a',
  );

  // The retried tool_a got stale guidance — not "Unknown tool".
  const staleGuidance = toolResultContents(driver).find((c) =>
    c.includes('was available earlier but is not part of the current step'),
  );
  assert.ok(staleGuidance, 'a previously-available tool should receive stale guidance');
  assert.not.ok(
    toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')),
    'a previously-available tool must not be reported as a hallucination',
  );

  // The retried call is flagged unknown + stale for the UI.
  const retried = allToolCalls(driver).filter(
    (tc) => tc.name === 'tool_a' && isChatToolCallUnknown(tc),
  );
  assert.is(retried.length, 1, 'exactly one tool_a call should be flagged unknown');
  assert.ok(isChatToolCallUnknown(retried[0]) && retried[0].stale === true, 'and marked stale');

  // The occurrence is recorded to the meta-event log for the download log.
  assert.ok(
    unresolvedEvents(sessionKey).some((d) => d.kind === 'stale' && d.tool === 'tool_a'),
    'a stale tool.unresolved meta event should be recorded',
  );
});

stale('reports a never-seen tool as a hallucinated unknown tool', async () => {
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('real_tool')],
    toolHandlers: { real_tool: async () => 'ok' },
  });
  const provider = scriptedProvider([callsTool('made_up', 'm1')]);
  const sessionKey = 'hallucination-meta-test';
  const driver = makeDriver(config, provider, sessionKey);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('Unknown tool: made_up'),
    'a tool never advertised should be reported as unknown',
  );
  const call = allToolCalls(driver).find((tc) => tc.name === 'made_up');
  assert.ok(call && isChatToolCallUnknown(call), 'the call should be flagged unknown');
  assert.not.ok(
    (call as { stale?: boolean }).stale,
    'a hallucinated tool must NOT be flagged stale',
  );

  assert.ok(
    unresolvedEvents(sessionKey).some((d) => d.kind === 'unknown' && d.tool === 'made_up'),
    'an unknown tool.unresolved meta event should be recorded',
  );
});

stale('points the model at the close tool when an exclusive fold hides a base tool', async () => {
  const fold = createToolFold({
    name: 'my_fold',
    tools: [def('inner_tool')],
    handlers: { inner_tool: async () => 'inner done' },
    // exclusive defaults to true — opening it removes base_tool from the set.
  });
  const config = agent({
    name: 'Folded',
    toolDefinitions: [def('base_tool'), fold.definition],
    toolHandlers: { base_tool: async () => 'base done', ...fold.handler },
  });

  const provider = scriptedProvider([
    callsTool('my_fold', 'f1'), // open the exclusive fold — base_tool now hidden
    callsTool('base_tool', 'b1'), // hidden behind the open fold
  ]);
  const sessionKey = 'fold-meta-test';
  const driver = makeDriver(config, provider, sessionKey);

  await driver.sendMessage('go');

  // Target the base_tool result specifically — the fold-open result also
  // mentions my_fold, so match on the tool call id rather than substring.
  const guidance = driver
    .getHistory()
    .find((m) => m.role === 'tool' && m.toolResult?.toolCallId === 'b1')?.toolResult?.content;
  assert.ok(guidance, 'calling a fold-hidden tool should produce guidance');
  assert.match(guidance!, /not available while the "my_fold" fold is open/);
  assert.match(guidance!, /close_my_fold/);

  const hidden = allToolCalls(driver).find(
    (tc) => tc.name === 'base_tool' && isChatToolCallUnknown(tc),
  );
  assert.ok(
    hidden && isChatToolCallUnknown(hidden) && hidden.stale === true,
    'the hidden call is stale',
  );

  assert.ok(
    unresolvedEvents(sessionKey).some(
      (d) => d.kind === 'fold-hidden' && d.tool === 'base_tool' && d.fold === 'my_fold',
    ),
    'a fold-hidden tool.unresolved meta event should be recorded',
  );
});

stale('splits stale vs hallucinated tools on the unknown-tool-limit error', async () => {
  const sessionKey = 'stale-limit-test';
  clearMetaEventRegistry();

  let state: 'A' | 'B' = 'A';
  const config = agent({
    name: 'Stateful',
    toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
    toolHandlers: () =>
      state === 'A'
        ? {
            tool_a: async () => {
              state = 'B';
              return 'advanced to B';
            },
          }
        : { tool_b: async () => 'b done' },
  });

  // One real call to advance to B, then 10 consecutive stale calls — the 10th
  // trips the stale ceiling (MAX_STALE_TOOL_CALLS, 2x the hallucination limit) and ends the turn.
  const provider = scriptedProvider([
    callsTool('tool_a', 'real'),
    ...Array.from({ length: 10 }, (_unused, i) => callsTool('tool_a', `stale-${i}`)),
  ]);
  const driver = makeDriver(config, provider, sessionKey);

  const result = await driver.sendMessage('go');
  assert.is(result.reason, 'done');

  const limitError = getMetaEvents(sessionKey).find(
    (e) => e.type === 'turn.error' && e.detail?.reason === 'unknown-tool-limit',
  );
  assert.ok(limitError, 'hitting the limit should record an unknown-tool-limit turn.error');
  const detail = limitError!.detail!;
  assert.equal(detail.staleTools, ['tool_a'], 'tool_a should be classified as stale');
  assert.equal(detail.hallucinatedTools, [], 'nothing was hallucinated');

  // Every stale attempt — not just the final limit error — is in the download log.
  assert.is(
    unresolvedEvents(sessionKey).filter((d) => d.kind === 'stale').length,
    10,
    'each stale attempt should be recorded as its own tool.unresolved event',
  );

  // The user-facing turn ends with the apology, not a crash.
  const last = driver.getHistory().at(-1);
  assert.ok(last?.role === 'assistant' && last.content.startsWith("I'm sorry"));
});

stale.run();

// ---------------------------------------------------------------------------
// onUnresolvedTool hook — an agent can redirect an unresolved tool call
// ---------------------------------------------------------------------------

const hook = createLogicSuite('ChatDriver onUnresolvedTool hook');

hook('replaces the default for a hallucinated tool when the hook returns a string', async () => {
  const config = agent({
    name: 'Hooked',
    toolDefinitions: [def('real_tool')],
    toolHandlers: { real_tool: async () => 'ok' },
    onUnresolvedTool: ({ toolName, kind, availableTools }) =>
      `redirect: ${toolName} is ${kind}; use ${availableTools.join(', ')}`,
  });
  const provider = scriptedProvider([callsTool('made_up', 'm1')]);
  const driver = makeDriver(config, provider);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('redirect: made_up is unknown; use real_tool'),
    'the hook string should replace the default Unknown tool message',
  );
  assert.not.ok(
    toolResultContents(driver).some((c) => c.startsWith('Unknown tool:')),
    'the default unknown-tool message must not appear',
  );
});

hook('replaces the default for a stale tool when the hook returns a string', async () => {
  let state: 'A' | 'B' = 'A';
  const config = agent({
    name: 'HookedStateful',
    toolDefinitions: () => (state === 'A' ? [def('tool_a')] : [def('tool_b')]),
    toolHandlers: () =>
      state === 'A'
        ? {
            tool_a: async () => {
              state = 'B';
              return 'advanced to B';
            },
          }
        : { tool_b: async () => 'b done' },
    onUnresolvedTool: ({ toolName, kind }) => `redirect: ${toolName}/${kind}`,
  });
  const provider = scriptedProvider([
    callsTool('tool_a', 't1'), // real — advances A -> B
    callsTool('tool_a', 't2'), // stale — tool_a no longer in state B
    callsTool('tool_b', 't3'), // real — valid in state B
  ]);
  const driver = makeDriver(config, provider);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('redirect: tool_a/stale'),
    'the hook string should replace the default stale message',
  );
  assert.not.ok(
    toolResultContents(driver).some((c) =>
      c.includes('was available earlier but is not part of the current step'),
    ),
    'the default stale message must not appear',
  );
});

hook('falls back to the default when the hook returns undefined', async () => {
  const config = agent({
    name: 'HookUndefined',
    toolDefinitions: [def('real_tool')],
    toolHandlers: { real_tool: async () => 'ok' },
    onUnresolvedTool: () => undefined,
  });
  const provider = scriptedProvider([callsTool('made_up', 'm1')]);
  const driver = makeDriver(config, provider);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('Unknown tool: made_up'),
    'an undefined hook result should fall back to the default message',
  );
});

hook('falls back to the default when the hook returns a whitespace-only string', async () => {
  const config = agent({
    name: 'HookEmpty',
    toolDefinitions: [def('real_tool')],
    toolHandlers: { real_tool: async () => 'ok' },
    onUnresolvedTool: () => '   ',
  });
  const provider = scriptedProvider([callsTool('made_up', 'm1')]);
  const driver = makeDriver(config, provider);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('Unknown tool: made_up'),
    'a whitespace-only hook result should fall back to the default message',
  );
});

hook('falls back to the default when the hook throws', async () => {
  const config = agent({
    name: 'HookThrows',
    toolDefinitions: [def('real_tool')],
    toolHandlers: { real_tool: async () => 'ok' },
    onUnresolvedTool: () => {
      throw new Error('boom');
    },
  });
  const provider = scriptedProvider([callsTool('made_up', 'm1')]);
  const driver = makeDriver(config, provider);

  await driver.sendMessage('go');

  assert.ok(
    toolResultContents(driver).includes('Unknown tool: made_up'),
    'a throwing hook should fall back to the default message',
  );
});

hook.run();

// ---------------------------------------------------------------------------
// timeout handling — a transport `TimeoutError` surfaces a distinct message
// ---------------------------------------------------------------------------

const timeout = createLogicSuite('ChatDriver timeout handling');

/** A provider whose `chat` rejects with the `TimeoutError` the transports tag
 * a request timeout with (see gemini/anthropic `post`). */
const timesOutProvider = (): AIProvider => ({
  chat: async (): Promise<ChatMessage> => {
    throw new DOMException('Gemini request timed out', 'TimeoutError');
  },
});

timeout('surfaces a timeout-specific message instead of the generic failure', async () => {
  clearMetaEventRegistry();
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('noop')],
    toolHandlers: { noop: async () => 'ok' },
  });
  const sessionKey = 'timeout-test';
  const driver = makeDriver(config, timesOutProvider(), sessionKey);

  const result = await driver.sendMessage('go');
  assert.is(result.reason, 'done');

  // The turn ends with the timeout message, NOT the generic "something went wrong".
  const last = driver.getHistory().at(-1);
  assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
  assert.ok(last!.content.includes('timed out'), 'message names the timeout');
  assert.not.ok(
    last!.content.includes('something went wrong'),
    'must not fall through to the generic catch',
  );

  // Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
  // not swallowed silently.
  const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
  assert.ok(err, 'a turn.error should be recorded');
  assert.is(err!.detail?.reason, 'exception');
  assert.is(err!.detail?.name, 'TimeoutError');
});

timeout.run();

// ---------------------------------------------------------------------------
// cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
// ---------------------------------------------------------------------------

const cancel = createLogicSuite('ChatDriver cancellation');

cancel('threads a non-aborted signal into the provider call by default', async () => {
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('noop')],
    toolHandlers: { noop: async () => 'ok' },
  });
  const capture: { signal?: AbortSignal } = {};
  const provider: AIProvider = {
    chat: async (_h, _m, options): Promise<ChatMessage> => {
      capture.signal = options?.signal;
      return { role: 'assistant', content: 'hi' };
    },
  };
  const driver = makeDriver(config, provider, 'signal-plumb');

  await driver.sendMessage('go');

  assert.ok(capture.signal, 'provider received an options.signal');
  assert.not.ok(capture.signal!.aborted, 'and it is not aborted on a normal turn');
});

cancel('dispose() aborts the in-flight request and stops quietly', async () => {
  clearMetaEventRegistry();
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('noop')],
    toolHandlers: { noop: async () => 'ok' },
  });
  // A provider that hangs until its signal aborts, then rejects with the
  // signal's reason — mirroring how the transport surfaces an aborted fetch.
  const capture: { signal?: AbortSignal } = {};
  const provider: AIProvider = {
    chat: (_h, _m, options): Promise<ChatMessage> => {
      const signal = options?.signal;
      capture.signal = signal;
      return new Promise<ChatMessage>((_resolve, reject) => {
        if (!signal) return;
        if (signal.aborted) {
          reject(signal.reason);
          return;
        }
        signal.addEventListener('abort', () => reject(signal.reason), { once: true });
      });
    },
  };
  const driver = makeDriver(config, provider, 'cancel-test');

  const pending = driver.sendMessage('go');
  driver.dispose();
  const result = await pending;

  assert.is(result.reason, 'done');
  assert.ok(capture.signal?.aborted, 'the provider signal aborted on dispose');
  assert.is(capture.signal!.reason.name, 'AbortError');

  // Quiet: no assistant message appended (would otherwise pollute cached history).
  const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
  assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
  assert.not.ok(
    driver.getHistory().some((m) => m.content?.includes('something went wrong')),
    'must not surface the generic failure',
  );
});

cancel(
  'cancel() stops the turn with a "Stopped." marker and leaves the driver usable',
  async () => {
    clearMetaEventRegistry();
    const config = agent({
      name: 'Static',
      toolDefinitions: [def('noop')],
      toolHandlers: { noop: async () => 'ok' },
    });
    let call = 0;
    const provider: AIProvider = {
      chat: (_h, _m, options): Promise<ChatMessage> => {
        call += 1;
        if (call === 1) {
          // First turn hangs until its signal aborts (the user cancel). Must also
          // handle the already-aborted case: cancel can land before chat runs
          // (during the provider-resolution await), so the signal may arrive
          // aborted — mirror how real fetch rejects immediately in that case.
          const signal = options!.signal!;
          return new Promise<ChatMessage>((_resolve, reject) => {
            if (signal.aborted) {
              reject(signal.reason);
              return;
            }
            signal.addEventListener('abort', () => reject(signal.reason), { once: true });
          });
        }
        // Second turn resolves normally — proves the driver wasn't bricked.
        return Promise.resolve({ role: 'assistant', content: 'second response' });
      },
    };
    const driver = makeDriver(config, provider, 'cancel-stop');

    const pending = driver.sendMessage('go');
    driver.cancel();
    const result = await pending;

    assert.is(result.reason, 'done');
    // Subtle 'Stopped.' marker (system-event, not a generic failure).
    const last = driver.getHistory().at(-1);
    assert.is(last?.role, 'system-event');
    assert.is(last?.content, 'Stopped.');

    // The driver is still usable for the next message.
    const second = await driver.sendMessage('again');
    assert.is(second.reason, 'done');
    assert.ok(
      driver.getHistory().some((m) => m.content === 'second response'),
      'a fresh turn runs after a cancel',
    );
  },
);

cancel('a tool already running finishes, then the loop stops at the boundary', async () => {
  let toolFinished = false;
  let secondChatCalled = false;
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('slow')],
    toolHandlers: {
      slow: async () => {
        // Cancel mid-tool: the tool must still run to completion.
        driver.cancel();
        await Promise.resolve();
        toolFinished = true;
        return 'tool done';
      },
    },
  });
  let call = 0;
  const provider: AIProvider = {
    chat: async (): Promise<ChatMessage> => {
      call += 1;
      if (call === 1) {
        return {
          role: 'assistant',
          content: '',
          toolCalls: [{ id: 't1', name: 'slow', args: {} }],
        };
      }
      secondChatCalled = true;
      return { role: 'assistant', content: 'should not be reached' };
    },
  };
  const driver = makeDriver(config, provider, 'cancel-tool');

  const result = await driver.sendMessage('go');

  assert.is(result.reason, 'done');
  assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
  assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
  const last = driver.getHistory().at(-1);
  assert.is(last?.role, 'system-event');
  assert.is(last?.content, 'Stopped.');
});

cancel('cancel() is a no-op when no turn is running', () => {
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('noop')],
    toolHandlers: { noop: async () => 'ok' },
  });
  const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');

  driver.cancel(); // must not throw or append anything while idle

  assert.is(driver.getHistory().length, 0);
});

cancel.run();

// ---------------------------------------------------------------------------
// sub-agents — forced tool use + typed completion/failure union (GENC-1312)
//
// A child sub-agent driver shares the parent's provider registry, so one
// scripted queue drives both: script the parent's delegating turn, then the
// worker's turn(s), in order.
// ---------------------------------------------------------------------------

const subagent = createLogicSuite('ChatDriver sub-agents');

/** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
const completingWorker = (result: unknown): AgentConfig =>
  agent({
    name: 'worker',
    toolDefinitions: [def('finish')],
    toolHandlers: {
      finish: async (_args, ctx) => {
        ctx.completeSubAgent?.(result);
        return 'finished';
      },
    },
  });

/** A parent that delegates to `worker` and reports the outcome via `capture`. */
const delegatingParent = (sub: AgentConfig, capture: (outcome: unknown) => void): AgentConfig =>
  agent({
    name: 'boss',
    subAgents: [sub],
    toolDefinitions: [def('delegate')],
    toolHandlers: {
      delegate: async (_args, ctx) => {
        const outcome = await ctx.requestSubAgent!('worker', { task: 'do it' });
        capture(outcome);
        return outcome.ok ? 'sub-agent completed' : `sub-agent failed: ${outcome.reason}`;
      },
    },
  });

subagent('resolves { ok: true, result } when the sub-agent calls completeSubAgent', async () => {
  const sessionKey = 'subagent-success-events';
  clearMetaEventRegistry();

  let outcome: unknown;
  const parent = delegatingParent(completingWorker({ value: 42 }), (o) => {
    outcome = o;
  });
  const provider = scriptedProvider([
    callsTool('delegate', 'd1'), // parent delegates to the worker
    callsTool('finish', 'f1'), //   worker completes
  ]);

  const driver = makeDriver(parent, provider, sessionKey);
  await driver.sendMessage('go');

  assert.equal(outcome, { ok: true, result: { value: 42 } });
  // The delegation is bracketed on the parent timeline by started + completed
  // (no subagent.failed), mirroring turn.start/turn.end.
  const subEvents = getMetaEvents(sessionKey).filter((e) => e.type.startsWith('subagent.'));
  assert.equal(
    subEvents.map((e) => e.type),
    ['subagent.started', 'subagent.completed'],
  );
  assert.is(subEvents[0].detail?.agent, 'worker');
  assert.is(subEvents[1].detail?.agent, 'worker');

  // The sub-agent trace lives on the tool CALL (read by the UI trace panel and
  // sumCosts) and is NOT duplicated onto the tool RESULT.
  const callMsg = driver.getHistory().find((m) => m.toolCalls?.some((c) => c.name === 'delegate'));
  const delegateCall = callMsg?.toolCalls?.find((c) => c.name === 'delegate');
  assert.ok(delegateCall?.subAgentTrace?.length, 'the tool call carries the sub-agent trace');
  const resultMsg = driver.getHistory().find((m) => m.role === 'tool' && !!m.toolResult);
  assert.not.ok(
    (resultMsg?.toolResult as { subAgentTrace?: unknown } | undefined)?.subAgentTrace,
    'the tool result must not duplicate the trace',
  );
});

subagent('forces tool use on the sub-agent turn but not the parent turn', async () => {
  const parent = delegatingParent(completingWorker({ done: true }), () => {});
  const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);

  await makeDriver(parent, provider).sendMessage('go');

  // Call 0 is the parent's turn (may-call); call 1 is the worker's turn (must-call).
  assert.is(provider.toolChoicePerCall[0], undefined, 'parent turn is not forced');
  assert.is(provider.toolChoicePerCall[1], 'required', 'sub-agent turn forces a tool call');
  assert.ok(
    provider.advertisedPerCall[1].includes('finish'),
    'the worker advertised its completion tool',
  );
});

subagent(
  'resolves { ok: false, reason } and records telemetry when the sub-agent never completes',
  async () => {
    const sessionKey = 'subagent-unknown-tool-test';
    clearMetaEventRegistry();

    let outcome: unknown;
    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('real')],
      toolHandlers: { real: async () => 'ok' },
    });
    const parent = delegatingParent(worker, (o) => {
      outcome = o;
    });

    // The worker repeatedly calls a tool it was never given, tripping the
    // unknown-tool limit (DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5) without completing.
    const provider = scriptedProvider([
      callsTool('delegate', 'd1'),
      ...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
    ]);

    await makeDriver(parent, provider, sessionKey).sendMessage('go');

    assert.equal(outcome, { ok: false, reason: 'unknown_tool_limit' });
    // The failure surfaces as a high-importance `subagent.failed` meta event,
    // recorded under the PARENT driver's session so it lands on the user-visible
    // debug-log timeline — not orphaned in the child's own session bucket.
    assert.ok(
      getMetaEvents(sessionKey).some(
        (e) =>
          e.type === 'subagent.failed' &&
          e.detail?.agent === 'worker' &&
          e.detail?.reason === 'unknown_tool_limit',
      ),
      'a subagent.failed meta event should be recorded under the parent session',
    );
    assert.not.ok(
      getMetaEvents('').some((e) => e.type === 'subagent.failed'),
      'the failure must not be orphaned in the child default session bucket',
    );
  },
);

subagent(
  "folds the sub-agent's high-value meta events into the parent session, breadcrumbed",
  async () => {
    const sessionKey = 'subagent-meta-harvest-test';
    clearMetaEventRegistry();

    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('real')],
      toolHandlers: { real: async () => 'ok' },
    });
    const parent = delegatingParent(worker, () => {});
    // The worker trips the unknown-tool limit — emitting tool.unresolved + a
    // turn.error under its own transient session, which the parent harvests.
    const provider = scriptedProvider([
      callsTool('delegate', 'd1'),
      ...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
    ]);

    await makeDriver(parent, provider, sessionKey).sendMessage('go');

    const events = getMetaEvents(sessionKey);
    // The sub-agent's turn.error is folded onto the parent timeline, breadcrumbed.
    const harvestedError = events.find(
      (e) => e.type === 'turn.error' && e.detail?.subAgent === 'boss › worker',
    );
    assert.ok(harvestedError, "the sub-agent's turn.error is folded in, breadcrumbed");
    assert.is(harvestedError!.detail?.reason, 'unknown-tool-limit');
    // ... as are its per-attempt tool.unresolved signals.
    assert.ok(
      events.some((e) => e.type === 'tool.unresolved' && e.detail?.subAgent === 'boss › worker'),
      "the sub-agent's tool.unresolved events are folded in too",
    );
    // High-volume, message-derivable events are NOT merged.
    assert.not.ok(
      events.some((e) => e.type === 'turn.start' && e.detail?.subAgent === 'boss › worker'),
      'turn.start is intentionally excluded from the harvest',
    );
    // The child's transient bucket is cleared, never polluting the empty-key sink.
    assert.is(getMetaEvents('').length, 0);
  },
);

subagent(
  'defaults to { ok: false, reason: "max_iterations" } when the sub-agent ends without completing',
  async () => {
    const sessionKey = 'subagent-default-fail-test';
    clearMetaEventRegistry();

    let outcome: unknown;
    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('noop')],
      toolHandlers: { noop: async () => 'ok' },
    });
    const parent = delegatingParent(worker, (o) => {
      outcome = o;
    });
    // No script for the worker turn → it returns a plain-text reply and ends
    // without ever calling a completion tool (the child records no explicit
    // failure reason).
    const provider = scriptedProvider([callsTool('delegate', 'd1')]);

    await makeDriver(parent, provider, sessionKey).sendMessage('go');

    assert.equal(outcome, { ok: false, reason: 'max_iterations' });
    // Even the defensive default is reported to the parent session — this is the
    // only telemetry path when the child recorded no explicit failure.
    assert.ok(
      getMetaEvents(sessionKey).some(
        (e) => e.type === 'subagent.failed' && e.detail?.reason === 'max_iterations',
      ),
      'the default failure should still record a subagent.failed meta event',
    );
  },
);

subagent(
  "forwards the sub-agent's turns onto the parent timeline, numbered under the activating turn",
  async () => {
    const parent = delegatingParent(completingWorker({ done: true }), () => {});
    const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
    const driver = makeDriver(parent, provider);

    await driver.sendMessage('go');

    const snaps = driver.getTurnSnapshots();
    // Parent turn 0 activated the sub-agent, so the worker's single turn is "0-1".
    const childSnap = snaps.find((s) => s.turnIndex === '0-1');
    assert.ok(childSnap, 'the sub-agent\'s turn should be forwarded as "0-1"');
    assert.is(childSnap!.agentName, 'worker', 'the forwarded snapshot keeps the sub-agent name');
    assert.ok(childSnap!.toolNames.includes('finish'), 'and records the tools the sub-agent saw');
    // The parent's own turns stay numeric.
    assert.ok(
      snaps.some((s) => s.turnIndex === '0'),
      'the activating parent turn is present as a bare string counter',
    );
  },
);

subagent(
  'resolves { ok: false, reason: "timeout" } and aborts the run when the sub-agent exceeds timeoutMs',
  async () => {
    const sessionKey = 'subagent-timeout-test';
    clearMetaEventRegistry();

    let outcome: unknown;
    // The worker would complete if allowed to run — but its turn hangs (below),
    // so the parent's timeout fires first.
    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('finish')],
      toolHandlers: {
        finish: async (_args, ctx) => {
          ctx.completeSubAgent?.({ ok: true });
          return 'finished';
        },
      },
    });
    const parent = agent({
      name: 'boss',
      subAgents: [worker],
      toolDefinitions: [def('delegate')],
      toolHandlers: {
        delegate: async (_args, ctx) => {
          const o = await ctx.requestSubAgent!('worker', { task: 'do it', timeoutMs: 20 });
          outcome = o;
          return o.ok ? 'sub-agent completed' : `sub-agent failed: ${o.reason}`;
        },
      },
    });

    // Call 0: parent delegates. Call 1: the worker's turn hangs until its signal
    // aborts (the timeout-driven dispose). Call 2: the parent wraps up.
    let call = 0;
    const provider: AIProvider = {
      chat: (_h, _m, options): Promise<ChatMessage> => {
        const n = call;
        call += 1;
        if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
        if (n === 1) {
          return new Promise<ChatMessage>((_resolve, reject) => {
            const signal = options?.signal;
            if (!signal) return;
            if (signal.aborted) {
              reject(signal.reason);
              return;
            }
            signal.addEventListener('abort', () => reject(signal.reason), { once: true });
          });
        }
        return Promise.resolve({ role: 'assistant', content: 'done' });
      },
    };

    await makeDriver(parent, provider, sessionKey).sendMessage('go');

    assert.equal(outcome, { ok: false, reason: 'timeout' });
    // Recorded under the parent session, like every other sub-agent failure.
    assert.ok(
      getMetaEvents(sessionKey).some(
        (e) => e.type === 'subagent.failed' && e.detail?.reason === 'timeout',
      ),
      'a subagent.failed meta event with reason "timeout" should be recorded',
    );
  },
);

subagent(
  'times out (without hanging) when the sub-agent is parked on a user interaction',
  async () => {
    const sessionKey = 'subagent-interaction-timeout';
    clearMetaEventRegistry();

    let outcome: unknown;
    // The worker asks a question and waits; the user never answers. The parent's
    // timeout must cancel that interaction and unblock the worker — if it didn't,
    // `await runPromise` inside invokeSubAgent would hang here forever (the worker
    // is parked on a promise only the parent can settle), and this test would
    // never resolve.
    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('ask')],
      toolHandlers: {
        ask: async (_args, ctx) => {
          const r = await ctx.requestInteraction('q', {}); // parks on the parent
          return { answered: r };
        },
      },
    });
    const parent = agent({
      name: 'boss',
      subAgents: [worker],
      toolDefinitions: [def('delegate')],
      toolHandlers: {
        delegate: async (_args, ctx) => {
          const o = await ctx.requestSubAgent!('worker', { task: 'ask', timeoutMs: 20 });
          outcome = o;
          return o.ok ? 'ok' : `failed: ${o.reason}`;
        },
      },
    });

    // call 0: parent delegates. call 1: worker asks (parks on the interaction).
    // Later calls: the disposed worker's turn sees an aborted signal and rejects;
    // the parent's resuming turn (not aborted) ends with text.
    let call = 0;
    const provider: AIProvider = {
      chat: (_h, _m, options): Promise<ChatMessage> => {
        const n = call;
        call += 1;
        if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
        if (n === 1) return Promise.resolve(callsTool('ask', 'a1'));
        const signal = options?.signal;
        if (signal?.aborted) return Promise.reject(signal.reason);
        return Promise.resolve({ role: 'assistant', content: 'done' });
      },
    };

    await makeDriver(parent, provider, sessionKey).sendMessage('go');

    assert.equal(outcome, { ok: false, reason: 'timeout' });
    assert.ok(
      getMetaEvents(sessionKey).some(
        (e) => e.type === 'subagent.failed' && e.detail?.reason === 'timeout',
      ),
      'subagent.failed (timeout) should be recorded — proving it neither hung nor skipped logging',
    );
  },
);

subagent(
  'fails the sub-agent with reason "timeout" (no apology text) on a transport TimeoutError',
  async () => {
    const sessionKey = 'subagent-transport-timeout-test';
    clearMetaEventRegistry();

    let outcome: unknown;
    const worker = agent({
      name: 'worker',
      toolDefinitions: [def('finish')],
      toolHandlers: {
        finish: async (_args, ctx) => {
          ctx.completeSubAgent?.({ ok: true });
          return 'finished';
        },
      },
    });
    const parent = delegatingParent(worker, (o) => {
      outcome = o;
    });

    // Call 0: parent delegates. Call 1: the worker's request times out at the
    // transport (tagged TimeoutError). Call 2: the parent wraps up.
    let call = 0;
    const provider: AIProvider = {
      chat: (): Promise<ChatMessage> => {
        const n = call;
        call += 1;
        if (n === 0) return Promise.resolve(callsTool('delegate', 'd1'));
        if (n === 1) {
          return Promise.reject(new DOMException('Anthropic request timed out', 'TimeoutError'));
        }
        return Promise.resolve({ role: 'assistant', content: 'done' });
      },
    };

    const driver = makeDriver(parent, provider, sessionKey);
    await driver.sendMessage('go');

    // The parent gets a typed timeout — not the wrong max_iterations default it
    // used to see when the child answered with the user-facing apology instead.
    assert.equal(outcome, { ok: false, reason: 'timeout' });
    assert.ok(
      getMetaEvents(sessionKey).some(
        (e) =>
          e.type === 'subagent.failed' &&
          e.detail?.agent === 'worker' &&
          e.detail?.reason === 'timeout',
      ),
      'a subagent.failed meta event with reason "timeout" should be recorded',
    );
    // The child must not speak to the user.
    assert.not.ok(
      driver.getHistory().some((m) => m.content?.includes('The request timed out')),
      'the sub-agent must not append the user-facing timeout apology',
    );
  },
);

subagent.run();

// ---------------------------------------------------------------------------
// cost / token attribution on the thinking-step split (GENC-1410)
// ---------------------------------------------------------------------------

const costAttribution = createLogicSuite('ChatDriver cost attribution (thinking-step split)');

costAttribution(
  'a text-plus-tool-call response contributes its cost and tokens exactly once',
  async () => {
    const config = agent({
      name: 'Worker',
      toolDefinitions: () => [def('do_thing')],
      toolHandlers: () => ({ do_thing: async () => 'ok' }),
    });
    // A single response that emits reasoning + interstitial narration + a tool call, carrying that
    // response's usage. The driver splits it into three messages — a `reasoning` bubble, a
    // `narration` bubble, and the tool-call message — but only ONE may carry the cost/tokens or the
    // session totals double-count this one response (GENC-1410).
    const provider = scriptedProvider([
      {
        role: 'assistant',
        content: 'Let me act.',
        reasoning: 'Let me think.',
        toolCalls: [{ id: 't1', name: 'do_thing', args: {} }],
        cost: 0.05,
        inputTokens: 1000,
        outputTokens: 50,
      },
      // Turn-ending reply (no tool calls, no usage) so the loop terminates cleanly.
      { role: 'assistant', content: 'Done.' },
    ]);
    const driver = makeDriver(config, provider);

    await driver.sendMessage('go');

    const history = driver.getHistory();
    const reasoning = history.find((m) => m.category === 'reasoning');
    const narration = history.find((m) => m.category === 'narration');
    const toolCallMsg = history.find((m) => m.toolCalls?.some((c) => c.name === 'do_thing'));

    // The one response was split into reasoning + narration bubbles + the tool-call message.
    assert.ok(reasoning, 'a reasoning bubble is emitted for the chain-of-thought summary');
    assert.is(reasoning!.content, 'Let me think.');
    assert.ok(narration, 'a narration bubble is emitted for the interstitial prose');
    assert.is(narration!.content, 'Let me act.');
    assert.ok(toolCallMsg, 'the tool call is on its own message');
    assert.is(toolCallMsg!.content, '');

    // Only the tool-call message carries the turn's usage; the display bubbles carry none.
    for (const m of [reasoning!, narration!]) {
      assert.is(m.cost, undefined, 'a display bubble must not carry cost');
      assert.is(m.inputTokens, undefined, 'nor inputTokens');
      assert.is(m.outputTokens, undefined, 'nor outputTokens');
    }
    assert.is(toolCallMsg!.cost, 0.05, 'the tool-call message carries the cost');
    assert.is(toolCallMsg!.inputTokens, 1000);
    assert.is(toolCallMsg!.outputTokens, 50);

    // The reasoning/narration bubbles are UI-only; they must not carry the tool call either.
    assert.is(reasoning!.toolCalls, undefined, 'the reasoning bubble carries no tool call');
    assert.is(narration!.toolCalls, undefined, 'the narration bubble carries no tool call');

    // The session totals count the one response once, not twice.
    assert.is(sumCosts(history), 0.05, 'cost is summed once');
    assert.is(sumTokens(history), 1050, 'tokens are summed once');
  },
);

costAttribution.run();

// ---------------------------------------------------------------------------
// per-agent / per-state temperature & tool-call mode (GENC-1321)
//
// The driver resolves `temperature` and `toolChoice` the same way it resolves
// `provider`: a static value, or a function of the turn context (which carries
// the live state for stateful agents). These tests assert both forms reach the
// provider call, and that the per-turn (function) form re-resolves each call.
// ---------------------------------------------------------------------------

const settings = createLogicSuite('ChatDriver temperature & toolChoice');

settings('passes a static agent temperature and tool-call mode to the provider', async () => {
  const provider = scriptedProvider([]);
  const driver = makeDriver(
    agent({
      name: 'a',
      temperature: 0.3,
      toolChoice: 'none',
      cachePolicy: { scope: 'prompt' },
      tailContext: 'STATE',
    }),
    provider,
  );

  await driver.sendMessage('hi');

  assert.is(provider.temperaturePerCall[0], 0.3);
  assert.equal(provider.toolChoicePerCall[0], 'none');
  assert.equal(provider.cachePolicyPerCall[0], { scope: 'prompt' });
  // The driver frames the raw tail content in a <system-reminder> marker.
  assert.is(provider.tailContextPerCall[0], '<system-reminder>\nSTATE\n</system-reminder>');
  // ...and surfaced in the per-turn debug snapshot (the effective values sent).
  const snap = driver.getTurnSnapshots()[0];
  assert.is(snap.temperature, 0.3);
  assert.equal(snap.toolChoice, 'none');
  // The framed tail is captured on the snapshot too — it is never stored in
  // history, so the snapshot is the only record of what the model actually saw.
  assert.is(snap.tailContext, '<system-reminder>\nSTATE\n</system-reminder>');
});

settings('re-resolves the function form per turn (carrying turn context)', async () => {
  const provider = scriptedProvider([callsTool('tool_a', 't1')]);
  const config = agent({
    name: 'b',
    toolDefinitions: [def('tool_a')],
    toolHandlers: { tool_a: async () => 'ok' },
    // turnIndex: 0 on the first LLM call, > 0 on subsequent tool-loop iterations.
    temperature: (ctx) => (ctx.turnIndex === 0 ? 0.1 : 0.9),
    toolChoice: (ctx) => (ctx.turnIndex === 0 ? { tool: 'tool_a' } : 'auto'),
    cachePolicy: (ctx) => (ctx.turnIndex === 0 ? { scope: 'history' } : { scope: 'tools' }),
  });

  await makeDriver(config, provider).sendMessage('go');

  // First call forces the named tool at a low temperature...
  assert.is(provider.temperaturePerCall[0], 0.1);
  assert.equal(provider.toolChoicePerCall[0], { tool: 'tool_a' });
  assert.equal(provider.cachePolicyPerCall[0], { scope: 'history' });
  // ...the follow-up call (after the tool result) sees the other branch.
  assert.is(provider.temperaturePerCall[1], 0.9);
  assert.equal(provider.toolChoicePerCall[1], 'auto');
  assert.equal(provider.cachePolicyPerCall[1], { scope: 'tools' });
});

settings(
  'leaves temperature and toolChoice unset when the agent does not configure them',
  async () => {
    const provider = scriptedProvider([]);

    await makeDriver(agent({ name: 'c' }), provider).sendMessage('hi');

    assert.is(provider.temperaturePerCall[0], undefined);
    assert.is(provider.toolChoicePerCall[0], undefined);
    assert.is(provider.cachePolicyPerCall[0], undefined);
    assert.is(provider.tailContextPerCall[0], undefined);
  },
);

// GENC-1380 A3b: the framework's malformed/empty retry nudge is routed to the framed tail (so the
// system prompt stays byte-stable for caching), not appended to the system string.
settings(
  'routes the empty-response retry nudge into the framed tail, not the system prompt',
  async () => {
    // First call returns empty → the driver retries; the second exhausts the queue and ends the turn.
    const provider = scriptedProvider([{ role: 'assistant', content: '' }]);

    const driver = makeDriver(agent({ name: 'r', systemPrompt: 'BASE' }), provider);
    await driver.sendMessage('go');

    // The retry call (index 1) carries the nudge in the framed tail...
    assert.ok(provider.tailContextPerCall[1]?.startsWith('<system-reminder>'), 'tail is framed');
    assert.ok(
      provider.tailContextPerCall[1]?.includes('You must respond'),
      'retry nudge is in the tail',
    );
    // ...and the system prompt stays the bare agent prompt on both calls (nudge moved, not copied).
    assert.is(provider.systemPromptPerCall[0], 'BASE');
    assert.is(provider.systemPromptPerCall[1], 'BASE');

    // Regression guard (debug-log reconstruction): the tail is never stored in
    // history, so the per-turn snapshot must capture it — otherwise the retry
    // nudge the model actually received would be invisible in the export. The
    // snapshot's (systemPrompt + tailContext) must equal what the provider saw.
    const retrySnap = driver.getTurnSnapshots()[1];
    assert.is(retrySnap.systemPrompt, provider.systemPromptPerCall[1]);
    assert.is(retrySnap.tailContext, provider.tailContextPerCall[1]);
    assert.ok(retrySnap.tailContext?.includes('You must respond'), 'nudge reconstructable');
    // The first (non-retry) call had no framework suffix and no agent tail → empty.
    assert.is(driver.getTurnSnapshots()[0].tailContext, undefined);
  },
);

settings.run();

// ---------------------------------------------------------------------------
// empty-response diagnostics (GENC-1321)
//
// The transport surfaces a provider diagnostic (Gemini: finishReason,
// thoughtsTokens, parts, blockReason) on the message; the driver must fold it
// into the empty-response meta events so the debug-log timeline shows *why* a
// turn came back blank — not just that it did.
// ---------------------------------------------------------------------------

const emptyDiag = createLogicSuite('ChatDriver empty-response diagnostics');

emptyDiag('folds the provider responseMeta into the empty-response meta events', async () => {
  clearMetaEventRegistry();
  const provider: AIProvider = {
    chat: async (): Promise<ChatMessage> => ({
      role: 'assistant',
      content: '',
      responseMeta: {
        finishReason: 'STOP',
        thoughtsTokens: 999,
        parts: { functionCall: 0, thought: 0, text: 0 },
      },
    }),
  };
  const sessionKey = 'empty-diag';

  await makeDriver(agent({ name: 'Static' }), provider, sessionKey).sendMessage('go');

  // Both the in-turn retries and the final bail carry the diagnostic.
  const retry = getMetaEvents(sessionKey).find(
    (e) => e.type === 'turn.retry' && e.detail?.reason === 'empty-response',
  );
  assert.ok(retry, 'an empty-response turn.retry should be recorded');
  assert.is(retry!.detail?.finishReason, 'STOP');
  assert.is(retry!.detail?.thoughtsTokens, 999);

  const err = getMetaEvents(sessionKey).find(
    (e) => e.type === 'turn.error' && e.detail?.reason === 'empty-response',
  );
  assert.ok(err, 'an empty-response turn.error should be recorded after retries');
  assert.is(err!.detail?.finishReason, 'STOP');
  assert.is(err!.detail?.thoughtsTokens, 999);
});

emptyDiag.run();

// ---------------------------------------------------------------------------
// synthetic-user echo — a resolved interaction can optionally surface its
// outcome on the user's side of the chat (GENC-1330). The widget opts in by
// returning `userMessage`; the driver appends it as a display-only
// `'synthetic-user'` message that transports never serialize.
// ---------------------------------------------------------------------------

const syntheticUser = createLogicSuite('ChatDriver synthetic-user interaction echo');

syntheticUser('resolveInteraction with userMessage appends a synthetic-user message', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
  const interactionMsg = driver.getHistory().at(-1)!;
  const id = interactionMsg.interaction!.interactionId;

  driver.resolveInteraction(id, {
    status: 'approved',
    payload: { answer: 'Option A' },
    userMessage: 'Option A',
  });
  await pending;

  const history = driver.getHistory();
  const synthetic = history.filter((m) => m.role === 'synthetic-user');
  assert.is(synthetic.length, 1);
  assert.is(synthetic[0].content, 'Option A');

  // The original interaction message is still present and now marked resolved
  // (the existing resolved-widget mechanism is untouched).
  const resolved = history.find((m) => m.interaction?.interactionId === id);
  assert.ok(resolved?.interaction?.resolved);

  // Chronology: the user-side echo follows the (resolved) interaction.
  const idxInteraction = history.findIndex((m) => m.interaction?.interactionId === id);
  const idxSynthetic = history.findIndex((m) => m.role === 'synthetic-user');
  assert.ok(idxSynthetic > idxInteraction);

  // It is attributed to the agent that created it — same stamping as any other
  // appended message — so the debug log can say which agent produced it.
  assert.is(synthetic[0].agentName, resolved?.agentName);
});

syntheticUser(
  'omitted or whitespace-only userMessage appends no synthetic-user message',
  async () => {
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

    // Omitted entirely — the previous behaviour.
    const first = driver.requestInteraction('w', {});
    const firstId = driver.getHistory().at(-1)!.interaction!.interactionId;
    driver.resolveInteraction(firstId, { status: 'approved' });
    await first;

    // Whitespace-only — treated as absent, never appended.
    const second = driver.requestInteraction('w', {});
    const secondId = driver.getHistory().at(-1)!.interaction!.interactionId;
    driver.resolveInteraction(secondId, { status: 'approved', userMessage: '   ' });
    await second;

    assert.is(driver.getHistory().filter((m) => m.role === 'synthetic-user').length, 0);
  },
);

syntheticUser.run();

// ---------------------------------------------------------------------------
// interaction presentation — the per-call `presentation` option is stamped
// onto the appended interaction so the renderer can frame the widget, and is
// absent (the historical default) when the option is omitted.
// ---------------------------------------------------------------------------

const interactionPresentation = createLogicSuite('ChatDriver interaction presentation');

interactionPresentation(
  'presentation option is stamped onto the appended interaction',
  async () => {
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

    const pending = driver.requestInteraction('w', {}, { presentation: 'bubble' });
    const interaction = driver.getHistory().at(-1)!.interaction!;
    assert.is(interaction.presentation, 'bubble');

    // Clean up the pending interaction so it does not dangle.
    driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
    await pending;
  },
);

interactionPresentation('presentation is absent when the option is omitted', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  const pending = driver.requestInteraction('w', {});
  const interaction = driver.getHistory().at(-1)!.interaction!;
  assert.ok(!('presentation' in interaction));

  driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
  await pending;
});

interactionPresentation.run();

// ---------------------------------------------------------------------------
// interaction context lifecycle (GENC-1390) — the driver creates a live
// InteractionContext on requestInteraction and disposes+drops it on resolve, so
// widget state survives element remounts but never outlives the interaction.
// ---------------------------------------------------------------------------

const interactionContextLifecycle = createLogicSuite('ChatDriver interaction context');

interactionContextLifecycle('creates a context on request and returns it by id', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  const pending = driver.requestInteraction('w', {});
  const { interactionId } = driver.getHistory().at(-1)!.interaction!;

  const ctx = driver.getInteractionContext(interactionId);
  assert.ok(ctx, 'a context exists for the pending interaction');
  assert.is(ctx!.interactionId, interactionId);
  // Same instance on a second lookup — this stable identity is what survives remounts.
  assert.is(driver.getInteractionContext(interactionId), ctx, 'the context is a stable instance');

  driver.resolveInteraction(interactionId, { status: 'approved' });
  await pending;
});

interactionContextLifecycle('disposes and drops the context on resolve', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  const pending = driver.requestInteraction('w', {});
  const { interactionId } = driver.getHistory().at(-1)!.interaction!;

  let disposed = false;
  driver.getInteractionContext(interactionId)!.onDispose(() => {
    disposed = true;
  });

  driver.resolveInteraction(interactionId, { status: 'approved' });
  await pending;

  assert.ok(disposed, 'onDispose fired on resolve');
  assert.is(
    driver.getInteractionContext(interactionId),
    undefined,
    'the context is dropped after resolve',
  );
});

interactionContextLifecycle.run();

// ---------------------------------------------------------------------------
// interaction activity-bus signals (GENC-1346) — the driver brackets a parked
// widget interaction with `interaction-requested` / `interaction-resolved`, so
// turn-aware UI can distinguish "actively computing" from "parked awaiting the
// user". No tool-loop event fires at a park boundary, so these are the signal.
// ---------------------------------------------------------------------------

const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');

// These two tests need to observe what the driver publishes, so they inject a local
// in-memory bus (no `crossTabEvents` → no BroadcastChannel is ever opened, nothing to close)
// rather than the shared singleton. Every other suite lets the driver default to the no-op bus.

interactionBus('brackets a park with interaction-requested then -resolved', async () => {
  const events: string[] = [];
  const bus = new AgenticActivityBus();
  const unsubs = [
    bus.subscribe('interaction-requested', () => events.push('requested')),
    bus.subscribe('interaction-resolved', () => events.push('resolved')),
  ];
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);

  const pending = driver.requestInteraction('w', {});
  assert.equal(events, ['requested'], 'parking fires interaction-requested');

  const id = driver.getHistory().at(-1)!.interaction!.interactionId;
  driver.resolveInteraction(id, { status: 'approved' });
  await pending;
  assert.equal(events, ['requested', 'resolved'], 'resolving fires interaction-resolved');

  unsubs.forEach((u) => u());
  driver.dispose();
});

interactionBus('a timed-out interaction still fires interaction-resolved', async () => {
  const events: string[] = [];
  const bus = new AgenticActivityBus();
  const unsub = bus.subscribe('interaction-resolved', () => events.push('resolved'));
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), '', bus);

  // Never resolved by a user — the timeout path runs the same teardown, so it
  // must signal the bus too (else the button would stay enabled after a timeout).
  const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
  await pending;
  assert.equal(events, ['resolved'], 'the timeout resolution path also signals the bus');

  unsub();
  driver.dispose();
});

interactionBus.run();

// ---------------------------------------------------------------------------
// interaction timeout — requestInteraction({ timeoutMs }) resolves with a
// status:'timeout' result (never rejects) and closes the widget read-only.
// ---------------------------------------------------------------------------

const interactionTimeout = createLogicSuite('ChatDriver interaction timeout');

interactionTimeout(
  'resolves with status "timeout" when the user does not respond in time',
  async () => {
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

    const result = (await driver.requestInteraction(
      'planning-question',
      { question: 'Pick one' },
      { timeoutMs: 10 },
    )) as InteractionResult;

    assert.is(result.status, 'timeout');
    // The widget message is stamped resolved (renders read-only) with the timeout.
    const msg = driver.getHistory().find((m) => m.interaction);
    assert.ok(msg?.interaction?.resolved);
    assert.is((msg!.interaction!.resolved as InteractionResult).status, 'timeout');
  },
);

interactionTimeout('a user response before the deadline wins and cancels the timeout', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  // A long timeout that must NOT fire because we resolve immediately.
  const pending = driver.requestInteraction<InteractionResult<{ answer: string }>>(
    'planning-question',
    { question: 'Pick one' },
    { timeoutMs: 10_000 },
  );
  const id = driver.getHistory().at(-1)!.interaction!.interactionId;
  driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' } });

  const result = await pending;
  assert.is(result.status, 'approved');
  assert.is(result.payload?.answer, 'A');
});

interactionTimeout.run();

// ---------------------------------------------------------------------------
// interaction external cost — a widget may report its own (non-LLM) cost via
// InteractionResult.costUsd; the driver folds it onto the message as
// externalCostUsd so the host's sumCosts recompute includes it.
// ---------------------------------------------------------------------------

const interactionCost = createLogicSuite('ChatDriver interaction external cost');

interactionCost('folds a widget-reported costUsd onto the message as externalCostUsd', async () => {
  const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

  const pending = driver.requestInteraction('planning-question', { question: 'Pick one' });
  const id = driver.getHistory().at(-1)!.interaction!.interactionId;

  driver.resolveInteraction(id, { status: 'approved', payload: { answer: 'A' }, costUsd: 0.05 });
  await pending;

  const msg = driver.getHistory().find((m) => m.interaction?.interactionId === id);
  assert.is(msg?.externalCostUsd, 0.05);
});

interactionCost(
  'leaves externalCostUsd unset for a missing, zero, or negative costUsd',
  async () => {
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));

    // Omitted entirely.
    const p1 = driver.requestInteraction('w', {});
    const id1 = driver.getHistory().at(-1)!.interaction!.interactionId;
    driver.resolveInteraction(id1, { status: 'approved' });
    await p1;

    // Zero and negative are both ignored (only a positive, finite amount counts).
    const p2 = driver.requestInteraction('w', {});
    const id2 = driver.getHistory().at(-1)!.interaction!.interactionId;
    driver.resolveInteraction(id2, { status: 'approved', costUsd: 0 });
    await p2;

    const p3 = driver.requestInteraction('w', {});
    const id3 = driver.getHistory().at(-1)!.interaction!.interactionId;
    driver.resolveInteraction(id3, { status: 'approved', costUsd: -1 });
    await p3;

    assert.not.ok(driver.getHistory().some((m) => m.externalCostUsd != null));
  },
);

interactionCost.run();

// ---------------------------------------------------------------------------
// observable provider registry — runtime provider switching (GENC-1346)
//
// When the host registers an observable registry and swaps providers at
// runtime, the driver must drop its memoised lookups so the next turn resolves
// the new provider, re-emit `provider-changed` even when the resolved *name* is
// unchanged, and (critically) never leak its subscription — including for child
// sub-agent drivers that complete normally.
// ---------------------------------------------------------------------------

interface ObservableTestRegistry extends AIProviderRegistry {
  subscribe(listener: () => void): () => void;
  /** Swap the provider returned by `get`/`default` and notify subscribers. */
  swap(provider: AIProvider): void;
  /** Live subscriber count — lets tests assert there's no listener leak. */
  listenerCount(): number;
}

/**
 * A single-name (`'high'`) observable registry. `get`/`default` always return
 * the current provider, so swapping it mid-session models a same-name vendor
 * switch (the tier name stays `'high'`, the provider underneath changes).
 */
const makeObservableRegistry = (initial: AIProvider): ObservableTestRegistry => {
  let current = initial;
  const listeners = new Set<() => void>();
  return {
    get: () => current,
    default: () => current,
    defaultName: () => 'high',
    names: () => ['high'],
    getStatus: async () => null,
    listStatuses: async () => [],
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    swap(provider: AIProvider) {
      current = provider;
      for (const l of Array.from(listeners)) l();
    },
    listenerCount: () => listeners.size,
  };
};

const makeDriverWithRegistry = (config: AgentConfig, registry: AIProviderRegistry): ChatDriver => {
  const driver = new ChatDriver(registry, { maxToolIterations: 50, maxFoldOperations: 5 });
  driver.applyAgent(config);
  return driver;
};

const observable = createLogicSuite('ChatDriver observable provider registry');

observable(
  'a registry change clears the resolved-provider cache so the next turn uses the new provider',
  async () => {
    const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
    const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
    const registry = makeObservableRegistry(providerA);
    // A static provider name means lookups go through `resolvedProviderCache` —
    // the cache that must self-invalidate on a registry change.
    const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);

    await driver.sendMessage('first');
    assert.is(providerA.advertisedPerCall.length, 1, 'turn 1 resolves the original provider');
    assert.is(providerB.advertisedPerCall.length, 0);

    registry.swap(providerB); // notify → cache cleared

    await driver.sendMessage('second');
    assert.is(providerB.advertisedPerCall.length, 1, 'turn 2 resolves the swapped-in provider');
    assert.is(providerA.advertisedPerCall.length, 1, 'the stale provider is not reused');

    driver.dispose();
  },
);

observable('re-emits provider-changed on a same-name swap', async () => {
  const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
  const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
  const registry = makeObservableRegistry(providerA);
  const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);

  const names: string[] = [];
  driver.addEventListener('provider-changed', (e) => {
    names.push((e as CustomEvent<{ name: string }>).detail.name);
  });

  await driver.sendMessage('first');
  registry.swap(providerB);
  await driver.sendMessage('second');

  // The resolved name ('high') never changes, but the swap resets the
  // last-dispatched name so the cog can refresh — two events, not one.
  assert.equal(names, ['high', 'high']);

  driver.dispose();
});

observable('a non-observable registry is a no-op — turn runs, dispose does not throw', async () => {
  const provider = scriptedProvider([{ role: 'assistant', content: 'hi' }]);
  const driver = makeDriverWithRegistry(agent({ name: 'plain' }), makeRegistry(provider));
  await driver.sendMessage('go');
  assert.is(provider.advertisedPerCall.length, 1);
  driver.dispose(); // no subscription was wired — must still be safe
});

observable('a child sub-agent driver unsubscribes on completion — no listener leak', async () => {
  const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
  const registry = makeObservableRegistry(provider);
  const parent = delegatingParent(completingWorker({ ok: true }), () => {});
  const driver = makeDriverWithRegistry(parent, registry);

  assert.is(registry.listenerCount(), 1, 'the parent driver subscribed on construction');

  await driver.sendMessage('go');
  // The child subscribed during the run; if it didn't clean up on its (normal)
  // completion the registry would now hold two listeners.
  assert.is(registry.listenerCount(), 1, 'the completed child unsubscribed');

  driver.dispose();
  assert.is(registry.listenerCount(), 0, 'the parent unsubscribed on dispose');
});

observable.run();

// ---------------------------------------------------------------------------
// per-message model attribution (GENC-1346)
//
// Each model-produced assistant message carries `model` (the concrete model id
// the active provider's getStatus reports) and `providerName` (the registry slot
// it resolved under), so the exported debug log shows which model produced each
// message — and, since tool calls ride on the assistant message, each tool call.
// ---------------------------------------------------------------------------

/** A provider that replays scripted replies and reports `model` via getStatus
 *  (omitted entirely when `model` is undefined, to model a provider with no
 *  status). */
const modelProvider = (model: string | undefined, responses: ChatMessage[]): AIProvider => {
  const queue = [...responses];
  const provider: AIProvider = {
    chat: async (): Promise<ChatMessage> => queue.shift() ?? { role: 'assistant', content: 'done' },
  };
  if (model !== undefined) {
    provider.getStatus = async () => ({ provider: 'gemini', model });
  }
  return provider;
};

const modelAttr = createLogicSuite('ChatDriver per-message model attribution');

modelAttr('stamps the resolved model and registry name onto an assistant reply', async () => {
  const provider = modelProvider('gemini-2.5-flash-lite', [
    { role: 'assistant', content: 'hi there' },
  ]);
  const driver = makeDriver(agent({ name: 'plain' }), provider);

  await driver.sendMessage('hello');

  const reply = driver.getHistory().find((m) => m.role === 'assistant');
  assert.ok(reply, 'assistant reply present');
  assert.is(reply!.model, 'gemini-2.5-flash-lite', 'model id read from provider getStatus');
  assert.is(reply!.provider, 'gemini', 'vendor id read from provider getStatus');
  // makeRegistry registers a single provider under the name 'test'.
  assert.is(reply!.providerName, 'test', 'registry slot the turn resolved under');

  driver.dispose();
});

modelAttr(
  'attributes a tool-calling assistant message (and so its tool calls) to the model',
  async () => {
    const provider = modelProvider('claude-haiku-4-5-20251001', [
      callsTool('noop', 't1'),
      { role: 'assistant', content: 'finished' },
    ]);
    const config = agent({
      name: 'withTool',
      toolDefinitions: [def('noop')],
      toolHandlers: { noop: async () => 'ok' },
    });
    const driver = makeDriver(config, provider);

    await driver.sendMessage('go');

    const toolCallMsg = driver.getHistory().find((m) => (m.toolCalls?.length ?? 0) > 0);
    assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
    assert.is(toolCallMsg!.model, 'claude-haiku-4-5-20251001');
    assert.is(toolCallMsg!.provider, 'gemini');
    assert.is(toolCallMsg!.providerName, 'test');

    driver.dispose();
  },
);

modelAttr('picks up a model swapped behind a stable name', async () => {
  const before = modelProvider('gemini-2.5-flash-lite', [{ role: 'assistant', content: 'A' }]);
  const after = modelProvider('gemini-2.5-pro', [{ role: 'assistant', content: 'B' }]);
  const registry = makeObservableRegistry(before);
  const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);

  await driver.sendMessage('first');
  registry.swap(after); // notify → model cache cleared, next turn re-resolves
  await driver.sendMessage('second');

  const replies = driver.getHistory().filter((m) => m.role === 'assistant');
  assert.is(replies.at(-2)?.model, 'gemini-2.5-flash-lite', 'first turn keeps the original model');
  assert.is(replies.at(-1)?.model, 'gemini-2.5-pro', 'after the swap the new model is stamped');
  // The tier name ('high') never changed across the swap — only the model behind it.
  assert.is(replies.at(-1)?.providerName, 'high');

  driver.dispose();
});

modelAttr('omits the model key entirely when the provider reports no status', async () => {
  const provider = modelProvider(undefined, [{ role: 'assistant', content: 'no status here' }]);
  const driver = makeDriver(agent({ name: 'plain' }), provider);

  await driver.sendMessage('hi');

  const reply = driver.getHistory().find((m) => m.role === 'assistant');
  assert.ok(reply, 'assistant reply present');
  // No getStatus → no model. The key is left off, not set to undefined, so the
  // exported log carries no dead `model` line for this message.
  assert.not.ok('model' in reply!, 'model key is absent, not present-as-undefined');
  assert.is(reply!.providerName, 'test', 'provider name is still recorded');

  driver.dispose();
});

modelAttr.run();

// ---------------------------------------------------------------------------
// condenseWhen — tool-declared context condensation (wiring through the loop)
// ---------------------------------------------------------------------------

const condense = createLogicSuite('ChatDriver condenseWhen');

const bigBody = 'A'.repeat(2000);

/** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
const readsPath = (id: string, path: string): ChatMessage => ({
  role: 'assistant',
  content: '',
  toolCalls: [{ id, name: 'read', args: { path } }],
});

condense(
  'collapses a superseded result in the model slice but keeps stored history full',
  async () => {
    const sessionKey = 'condense-supersede';
    clearMetaEventRegistry();
    const provider = scriptedProvider([
      readsPath('r1', 'A'),
      readsPath('r2', 'A'), // re-read of the same path supersedes r1
    ]);
    const config = agent({
      name: 'reader',
      systemPrompt: 'read files',
      toolDefinitions: [def('read')],
      toolHandlers: {
        read: async (args, ctx) => {
          ctx.condenseWhen({
            on: { kind: 'superseded', by: String(args.path) },
            response: 'pointer',
          });
          return bigBody;
        },
      },
    });
    const driver = makeDriver(config, provider, sessionKey);

    const result = await driver.sendMessage('go');
    assert.is(result.reason, 'done');

    // The 3rd provider call (index 2) ran after both reads: r1 is superseded, r2 is latest.
    const slice = provider.historyPerCall[2];
    const r1 = slice.find((m) => m.toolResult?.toolCallId === 'r1');
    const r2 = slice.find((m) => m.toolResult?.toolCallId === 'r2');
    assert.match(
      r1!.toolResult!.content,
      /re-call to restore/,
      'earlier read collapsed for the model',
    );
    assert.is(r2!.toolResult!.content, bigBody, 'latest read still full');

    // Stored history is untouched — both reads remain full for the UI / debug export.
    assert.is(
      toolResultContents(driver).filter((c) => c === bigBody).length,
      2,
      'stored history retains both full reads',
    );

    // One context.condensed meta-event, for r1's response, recorded once.
    const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
    assert.is(events.length, 1, 'the full→stub transition is reported exactly once');
    assert.is(events[0].detail?.toolCallId, 'r1');
    assert.is(events[0].detail?.target, 'response');
  },
);

condense('first-wins: a second condenseWhen for the same tool call is ignored', async () => {
  const sessionKey = 'condense-first-wins';
  clearMetaEventRegistry();
  const provider = scriptedProvider([readsPath('r1', 'A'), readsPath('r2', 'A')]);
  const config = agent({
    name: 'reader',
    systemPrompt: 'read files',
    toolDefinitions: [def('read')],
    toolHandlers: {
      read: async (args, ctx) => {
        // First declaration wins; the second (a different mode) is dropped.
        ctx.condenseWhen({
          on: { kind: 'superseded', by: String(args.path) },
          response: 'pointer',
        });
        ctx.condenseWhen({
          on: { kind: 'superseded', by: String(args.path) },
          response: { replaceWith: 'SECOND' },
        });
        return bigBody;
      },
    },
  });
  const driver = makeDriver(config, provider, sessionKey);
  await driver.sendMessage('go');

  const collapsed = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'r1');
  assert.match(
    collapsed!.toolResult!.content,
    /re-call to restore/,
    'the first (pointer) policy applied',
  );
  assert.is.not(collapsed!.toolResult!.content, 'SECOND', 'the later policy was ignored');
});

condense('age clock is monotonic across turns — collapses on a later, separate turn', async () => {
  // Regression guard for the per-turn-reset bug: the age clock is the local loop
  // counter that resets every `sendMessage`, so an `age` payload from turn 1
  // would never collapse across short turns. The driver now uses a monotonic
  // model-call counter, so it does.
  const sessionKey = 'condense-age-cross-turn';
  clearMetaEventRegistry();
  const provider = scriptedProvider([
    { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
    { role: 'assistant', content: 'noted' }, // turn 1, call 2 — model sees n1 once (full), turn ends
    { role: 'assistant', content: 'ok' }, // turn 2, call 1 — a short follow-up, no tools
  ]);
  const config = agent({
    name: 'note-taker',
    systemPrompt: 'take notes',
    toolDefinitions: [def('notes')],
    toolHandlers: {
      notes: async (_args, ctx) => {
        ctx.condenseWhen({ on: { kind: 'age', turns: 1 }, response: 'pointer' });
        return bigBody;
      },
    },
  });
  const driver = makeDriver(config, provider, sessionKey);

  await driver.sendMessage('first'); // turn 1
  await driver.sendMessage('second'); // turn 2 — a separate, short sendMessage

  // Turn 1's consume call (provider call #1) saw n1 in full — its one allowed look.
  const turn1Consume = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
  assert.is(turn1Consume!.toolResult!.content, bigBody, 'full on its one allowed turn');

  // Turn 2's first call (#2) is a SEPARATE sendMessage — the local loop counter has
  // reset to 0, but the monotonic clock kept counting, so n1 is now collapsed.
  // Under the old per-turn counter it would still be full here.
  const turn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
  assert.match(turn2!.toolResult!.content, /re-call to restore/, 'collapsed on the next turn');

  assert.ok(toolResultContents(driver).includes(bigBody), 'stored history retains the full note');
  const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
  assert.is(events.length, 1);
  assert.is(events[0].detail?.trigger, 'age:1');
});

condense('turnEnd: full during its request, collapsed on the next user turn', async () => {
  const sessionKey = 'condense-turn-end';
  clearMetaEventRegistry();
  const provider = scriptedProvider([
    { role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
    { role: 'assistant', content: 'noted' }, // turn 1, call 2 — n1 still full this turn
    { role: 'assistant', content: 'ok' }, // turn 2 — a separate request
  ]);
  const config = agent({
    name: 'note-taker',
    systemPrompt: 'take notes',
    toolDefinitions: [def('notes')],
    toolHandlers: {
      notes: async (_args, ctx) => {
        ctx.condenseWhen({ on: { kind: 'turnEnd' }, response: 'pointer' });
        return bigBody;
      },
    },
  });
  const driver = makeDriver(config, provider, sessionKey);

  await driver.sendMessage('first'); // turn 1
  await driver.sendMessage('second'); // turn 2

  const inTurn1 = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 'n1');
  assert.is(inTurn1!.toolResult!.content, bigBody, 'full for the rest of the request it served');
  const inTurn2 = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 'n1');
  assert.match(inTurn2!.toolResult!.content, /re-call to restore/, 'collapsed once the turn ended');

  const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
  assert.is(events.length, 1);
  assert.is(events[0].detail?.trigger, 'turnEnd');
});

condense('agentEnd: collapses once the agent releases (flow complete)', async () => {
  const sessionKey = 'condense-agent-end';
  clearMetaEventRegistry();
  const provider = scriptedProvider([
    { role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
    { role: 'assistant', content: '', toolCalls: [{ id: 'f1', name: 'finish', args: {} }] }, // call 2 — releases
    { role: 'assistant', content: 'wrapped up' }, // call 3 — flow done
  ]);
  const config = agent({
    name: 'wizard',
    systemPrompt: 'a flow',
    toolDefinitions: [def('load'), def('finish')],
    toolHandlers: {
      load: async (_args, ctx) => {
        ctx.condenseWhen({ on: { kind: 'agentEnd' }, response: 'pointer' });
        return bigBody;
      },
      finish: async (_args, ctx) => {
        ctx.releaseAgent();
        return 'flow complete';
      },
    },
  });
  const driver = makeDriver(config, provider, sessionKey);
  await driver.sendMessage('run the flow');

  // call #2 (index 1) ran before `finish` released — schema still full.
  const beforeRelease = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
  assert.is(beforeRelease!.toolResult!.content, bigBody, 'full while the flow is active');
  // call #3 (index 2) ran after release — schema collapsed.
  const afterRelease = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
  assert.match(afterRelease!.toolResult!.content, /re-call to restore/, 'collapsed once released');

  const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
  assert.is(events.length, 1);
  assert.is(events[0].detail?.trigger, 'agentEnd');
});

condense('phaseEnd: collapses at endPhase() while the agent keeps running', async () => {
  const sessionKey = 'condense-phase-end';
  clearMetaEventRegistry();
  const provider = scriptedProvider([
    { role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
    { role: 'assistant', content: '', toolCalls: [{ id: 'a1', name: 'advance', args: {} }] }, // call 2 — ends the phase
    { role: 'assistant', content: 'continuing' }, // call 3 — agent still active, just answers
  ]);
  const config = agent({
    name: 'journey',
    systemPrompt: 'a multi-phase flow',
    toolDefinitions: [def('load'), def('advance')],
    toolHandlers: {
      load: async (_args, ctx) => {
        ctx.condenseWhen({ on: { kind: 'phaseEnd' }, response: 'pointer' });
        return bigBody;
      },
      advance: async (_args, ctx) => {
        // Declares a phase boundary WITHOUT ending the agent (no releaseAgent).
        ctx.endPhase?.();
        return 'next phase';
      },
    },
  });
  const driver = makeDriver(config, provider, sessionKey);
  const result = await driver.sendMessage('run the flow');
  // The agent never released — the turn ends normally on the text response.
  assert.is(result.reason, 'done');

  // call #2 (index 1) ran before `advance` ended the phase — result still full.
  const beforePhaseEnd = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
  assert.is(beforePhaseEnd!.toolResult!.content, bigBody, 'full while the phase is current');
  // call #3 (index 2) ran after endPhase() — collapsed. The activation never ended
  // (no release/swap), so this collapse can ONLY be the phase boundary, not the
  // agentEnd backstop.
  const afterPhaseEnd = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
  assert.match(afterPhaseEnd!.toolResult!.content, /re-call to restore/, 'collapsed at phase end');
  assert.match(afterPhaseEnd!.toolResult!.content, /phase ended/);

  const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
  assert.is(events.length, 1);
  assert.is(events[0].detail?.trigger, 'phaseEnd');
});

condense.run();

// ---------------------------------------------------------------------------
// turn-outcome surfacing (PTC-0) — the typed failure reason must reach BOTH
// the loop-boundary result (`ChatDriverResult.failureReason`) and the
// `tool-loop-end` activity-bus event detail. Historically every exit flattened
// to `{ reason: 'done' }` and the bus detail was `undefined`; these lock the
// two seams together, one per `TurnFailureReason`, plus a happy-path compat
// check that the legacy shape is byte-unchanged.
// ---------------------------------------------------------------------------

const outcome = createLogicSuite('ChatDriver turn-outcome surfacing');

// This branch routes activity events to an INJECTED bus (default NOOP), not the module
// singleton, so these tests inject a local bus and observe the same seam the driver publishes to.
const outcomeBus = new AgenticActivityBus();

outcome.after(() => {
  outcomeBus.close();
});

/**
 * Subscribe to `tool-loop-end` and expose the most recent detail seen. `fired()` reports
 * whether the event was seen at all — distinct from `detail()`, which is `undefined` both
 * when no event fired and when a clean turn fired with the historical `undefined` detail.
 */
const captureLoopEnd = (): {
  detail: () => { failureReason?: TurnFailureReason } | undefined;
  fired: () => boolean;
  stop: () => void;
} => {
  let last: { failureReason?: TurnFailureReason } | undefined;
  let seen = false;
  const stop = outcomeBus.subscribe('tool-loop-end', (d) => {
    last = d;
    seen = true;
  });
  return { detail: () => (seen ? last : undefined), fired: () => seen, stop };
};

/** A ChatDriver with an explicit (small) tool-iteration cap. */
const makeCappedDriver = (
  config: AgentConfig,
  provider: AIProvider,
  maxIterations: number,
  sessionKey = '',
): ChatDriver => {
  const driver = new ChatDriver(makeRegistry(provider), {
    maxToolIterations: maxIterations,
    maxFoldOperations: 5,
    sessionKey,
    activityBus: outcomeBus,
  });
  driver.applyAgent(config);
  return driver;
};

/**
 * Assert that a turn driven by `provider` surfaces `expected` at BOTH seams.
 * `driverFor` lets the max-iterations case swap in a low cap.
 */
const assertSurfacesReason = async (
  label: string,
  provider: AIProvider,
  expected: TurnFailureReason,
  driverFor: (config: AgentConfig, provider: AIProvider, sessionKey: string) => ChatDriver = (
    c,
    p,
    k,
  ) => makeDriver(c, p, k, outcomeBus),
): Promise<void> => {
  clearMetaEventRegistry();
  const sessionKey = `outcome-${label}`;
  const config = agent({
    name: 'Static',
    toolDefinitions: [def('noop')],
    toolHandlers: { noop: async () => 'ok' },
  });
  const driver = driverFor(config, provider, sessionKey);
  const cap = captureLoopEnd();

  const result: ChatDriverResult = await driver.sendMessage('go');

  // Seam 1 — the loop-boundary result. Discriminant stays 'done' (compat).
  assert.is(result.reason, 'done', `[${label}] discriminant stays 'done'`);
  assert.is(
    result.reason === 'done' ? result.failureReason : undefined,
    expected,
    `[${label}] result.failureReason surfaces the typed reason`,
  );

  // Seam 2 — the activity-bus tool-loop-end detail.
  assert.ok(cap.detail(), `[${label}] a tool-loop-end event fired`);
  assert.is(
    cap.detail()!.failureReason,
    expected,
    `[${label}] the bus detail carries the same reason`,
  );

  // Consistency: the debug-log turn.error records the same taxonomy.
  const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
  assert.ok(err, `[${label}] a turn.error is recorded`);
  assert.is(err!.detail?.reason, expected, `[${label}] the debug-log reason matches`);

  cap.stop();
};

/** A provider that returns a MALFORMED_FUNCTION_CALL on every call. */
const malformedProvider = (): AIProvider => ({
  chat: async (): Promise<ChatMessage> => {
    throw new MalformedFunctionCallError('bad call');
  },
});

/** A provider that returns an empty response on every call. */
const emptyProvider = (): AIProvider => ({
  chat: async (): Promise<ChatMessage> => ({ role: 'assistant', content: '' }),
});

/** A provider that throws a ResponseTruncatedError (deterministic, no retry). */
const truncatedProvider = (): AIProvider => ({
  chat: async (): Promise<ChatMessage> => {
    throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
  },
});

/** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
const throwingProvider = (): AIProvider => ({
  chat: async (): Promise<ChatMessage> => {
    throw new Error('boom');
  },
});

/** A provider that never stops calling a valid tool (drives the iteration cap). */
const neverStopsProvider = (): AIProvider => {
  let n = 0;
  return {
    chat: async (): Promise<ChatMessage> => {
      const id = `noop-${n}`;
      n += 1;
      return { role: 'assistant', content: '', toolCalls: [{ id, name: 'noop', args: {} }] };
    },
  };
};

/** A provider that keeps calling a tool with no handler (hallucinated → limit). */
const hallucinatedProvider = (): AIProvider =>
  scriptedProvider(Array.from({ length: 6 }, (_u, i) => callsTool('ghost', `ghost-${i}`)));

outcome('malformed-function-call surfaces at both seams', async () => {
  await assertSurfacesReason('malformed', malformedProvider(), 'malformed-function-call');
});

outcome('empty-response surfaces at both seams', async () => {
  await assertSurfacesReason('empty', emptyProvider(), 'empty-response');
});

outcome('response-truncated surfaces at both seams', async () => {
  await assertSurfacesReason('truncated', truncatedProvider(), 'response-truncated');
});

outcome('exception surfaces at both seams', async () => {
  await assertSurfacesReason('exception', throwingProvider(), 'exception');
});

outcome('unknown-tool-limit surfaces at both seams', async () => {
  await assertSurfacesReason('unknown-tool', hallucinatedProvider(), 'unknown-tool-limit');
});

outcome('max-iterations surfaces at both seams', async () => {
  await assertSurfacesReason('max-iter', neverStopsProvider(), 'max-iterations', (c, p, k) =>
    makeCappedDriver(c, p, 2, k),
  );
});

outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)', async () => {
  clearMetaEventRegistry();
  const config = agent({ name: 'Static' });
  // Plain-text reply ends the turn cleanly on the first call.
  const driver = makeDriver(
    config,
    scriptedProvider([{ role: 'assistant', content: 'hi' }]),
    'outcome-ok',
    outcomeBus,
  );
  const cap = captureLoopEnd();

  const result: ChatDriverResult = await driver.sendMessage('go');

  // The result is exactly `{ reason: 'done' }` — no `failureReason` key added.
  assert.equal(result, { reason: 'done' }, 'happy-path result is the historical shape');
  assert.not.ok('failureReason' in result, 'no failureReason key is present on a clean turn');

  // The bus event still fires, and its detail is the historical `undefined` — a clean
  // turn emits no detail object at all (byte-shape compat), not `{ failureReason: undefined }`.
  assert.ok(cap.fired(), 'a tool-loop-end event fired');
  assert.is(cap.detail(), undefined, 'a clean turn emits the historical undefined detail');

  // And no turn.error was recorded.
  assert.not.ok(
    getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'),
    'a clean turn records no turn.error',
  );

  cap.stop();
});

outcome.run();
