/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { FinishReason } from '@google/genai';
import { LegacyAgentSession } from './legacy-agent-session.js';
import type { LegacyAgentSessionDeps } from './legacy-agent-session.js';
import { GeminiEventType } from '../core/turn.js';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import type { AgentEvent, AgentSend } from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type {
  CompletedToolCall,
  ToolCallRequestInfo,
} from '../scheduler/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import type { GeminiClient } from '../core/client.js';
import type { Scheduler } from '../scheduler/scheduler.js';
import type { Config } from '../config/config.js';

// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------

function createMockDeps(
  overrides?: Partial<LegacyAgentSessionDeps>,
): Required<LegacyAgentSessionDeps> {
  const mockClient = {
    sendMessageStream: vi.fn(),
    getChat: vi.fn().mockReturnValue({
      recordCompletedToolCalls: vi.fn(),
    }),
    getCurrentSequenceModel: vi.fn().mockReturnValue(null),
  };

  const mockScheduler = {
    schedule: vi.fn().mockResolvedValue([]),
  };

  const mockConfig = {
    getMaxSessionTurns: vi.fn().mockReturnValue(-1),
    getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
    getGeminiClient: vi.fn().mockReturnValue(mockClient),
    getMessageBus: vi.fn().mockImplementation(() => ({
      subscribe: vi.fn(),
      unsubscribe: vi.fn(),
    })),
  };

  return {
    client: mockClient as unknown as GeminiClient,
    scheduler: mockScheduler as unknown as Scheduler,
    config: mockConfig as unknown as Config,
    promptId: 'test-prompt',
    streamId: 'test-stream',
    getPreferredEditor: vi.fn().mockReturnValue(undefined),
    ...overrides,
  } as Required<LegacyAgentSessionDeps>;
}

async function* makeStream(
  events: ServerGeminiStreamEvent[],
): AsyncGenerator<ServerGeminiStreamEvent> {
  for (const event of events) {
    yield event;
  }
}

function makeToolRequest(callId: string, name: string): ToolCallRequestInfo {
  return {
    callId,
    name,
    args: {},
    isClientInitiated: false,
    prompt_id: 'p1',
  };
}

function makeMessageSend(
  text: string,
  displayContent?: string,
): Extract<AgentSend, { message: unknown }> {
  return {
    message: {
      content: [{ type: 'text', text }],
      ...(displayContent ? { displayContent } : {}),
    },
  };
}

function makeCompletedToolCall(
  callId: string,
  name: string,
  responseText: string,
): CompletedToolCall {
  return {
    status: CoreToolCallStatus.Success,
    request: makeToolRequest(callId, name),
    response: {
      callId,
      responseParts: [{ text: responseText }],
      resultDisplay: responseText,
      display: {
        result: { type: 'text', text: responseText },
      },
      error: undefined,
      errorType: undefined,
    },

    tool: {} as CompletedToolCall extends { tool: infer T } ? T : never,

    invocation: {} as CompletedToolCall extends { invocation: infer T }
      ? T
      : never,
  } as CompletedToolCall;
}

async function collectEvents(
  session: LegacyAgentSession,
  options?: { streamId?: string; eventId?: string },
): Promise<AgentEvent[]> {
  const events: AgentEvent[] = [];
  const streamOptions =
    options?.eventId || options?.streamId ? options : undefined;

  for await (const event of streamOptions
    ? session.stream(streamOptions)
    : session.stream()) {
    events.push(event);
  }
  return events;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('LegacyAgentSession', () => {
  let deps: Required<LegacyAgentSessionDeps>;

  beforeEach(() => {
    deps = createMockDeps();
    vi.useFakeTimers({ shouldAdvanceTime: true });
  });

  describe('send', () => {
    it('returns streamId', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'hello' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      const result = await session.send(makeMessageSend('hi'));

      expect(result.streamId).toBe('test-stream');
    });

    it('records the sent user message in the trajectory before send resolves', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      const { streamId } = await session.send({
        message: {
          content: [{ type: 'text', text: 'hi' }],
          displayContent: 'raw input',
        },
        _meta: { source: 'user-test' },
      });

      const userMessage = session.events.find(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' && e.role === 'user' && e.streamId === streamId,
      );
      expect(userMessage?.content).toEqual([
        { type: 'text', text: 'raw input' },
      ]);
      expect(userMessage?._meta).toEqual({ source: 'user-test' });
      await vi.advanceTimersByTimeAsync(0);
      expect(sendMock).toHaveBeenCalledWith(
        [{ text: 'hi' }],
        expect.any(AbortSignal),
        'test-prompt',
        undefined,
        'raw input',
      );

      await collectEvents(session, { streamId: streamId ?? undefined });
    });

    it('returns streamId before emitting agent_start', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      const liveEvents: AgentEvent[] = [];
      session.subscribe((event) => {
        liveEvents.push(event);
      });

      const { streamId } = await session.send(makeMessageSend('hi'));

      expect(streamId).toBe('test-stream');
      expect(liveEvents.some((event) => event.type === 'agent_start')).toBe(
        false,
      );

      await collectEvents(session, { streamId: streamId ?? undefined });
      expect(liveEvents.some((event) => event.type === 'agent_start')).toBe(
        true,
      );
    });

    it('throws for non-message payloads', async () => {
      const session = new LegacyAgentSession(deps);
      await expect(session.send({ update: { title: 'test' } })).rejects.toThrow(
        'only supports message sends',
      );
    });

    it('throws if send is called while a stream is active', async () => {
      let resolveHang: (() => void) | undefined;
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        (async function* () {
          await new Promise<void>((resolve) => {
            resolveHang = resolve;
          });
          yield {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          } as ServerGeminiStreamEvent;
        })(),
      );

      const session = new LegacyAgentSession(deps);
      const { streamId } = await session.send(makeMessageSend('first'));
      await vi.advanceTimersByTimeAsync(0);

      await expect(session.send(makeMessageSend('second'))).rejects.toThrow(
        'cannot be called while a stream is active',
      );

      resolveHang?.();
      await collectEvents(session, { streamId: streamId ?? undefined });
    });

    it('creates a new streamId after the previous stream completes', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'first response' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        )
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'second response' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        );

      const session = new LegacyAgentSession(deps);
      const first = await session.send(makeMessageSend('first'));
      const firstEvents = await collectEvents(session, {
        streamId: first.streamId ?? undefined,
      });

      const second = await session.send(makeMessageSend('second'));
      const secondEvents = await collectEvents(session, {
        streamId: second.streamId ?? undefined,
      });
      const userMessages = session.events.filter(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' && e.role === 'user',
      );

      expect(first.streamId).not.toBe(second.streamId);
      expect(
        userMessages.some(
          (e) =>
            e.streamId === first.streamId &&
            e.content[0]?.type === 'text' &&
            e.content[0].text === 'first',
        ),
      ).toBe(true);
      expect(
        userMessages.some(
          (e) =>
            e.streamId === second.streamId &&
            e.content[0]?.type === 'text' &&
            e.content[0].text === 'second',
        ),
      ).toBe(true);
      expect(firstEvents.some((e) => e.type === 'agent_end')).toBe(true);
      expect(secondEvents.some((e) => e.type === 'agent_end')).toBe(true);
    });
  });

  describe('stream - basic flow', () => {
    it('emits agent_start, content messages, and agent_end', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'Hello' },
          { type: GeminiEventType.Content, value: ' World' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const types = events.map((e) => e.type);
      expect(types).toContain('agent_start');
      expect(types).toContain('message');
      expect(types).toContain('agent_end');

      const messages = events.filter(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' && e.role === 'agent',
      );
      expect(messages).toHaveLength(2);
      expect(messages[0]?.content).toEqual([{ type: 'text', text: 'Hello' }]);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('completed');
    });
  });

  describe('stream - tool calls', () => {
    it('handles a tool call round-trip', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      // First turn: model requests a tool
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'read_file'),
          },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );
      // Second turn: model provides final answer
      sendMock.mockReturnValueOnce(
        makeStream([
          { type: GeminiEventType.Content, value: 'Done!' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([
        makeCompletedToolCall('call-1', 'read_file', 'file contents'),
      ]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('read a file'));
      const events = await collectEvents(session);

      const types = events.map((e) => e.type);
      expect(types).toContain('tool_request');
      expect(types).toContain('tool_response');
      expect(types).toContain('agent_end');

      const toolReq = events.find(
        (e): e is AgentEvent<'tool_request'> => e.type === 'tool_request',
      );
      expect(toolReq?.name).toBe('read_file');

      const toolResp = events.find(
        (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
      );
      expect(toolResp?.name).toBe('read_file');
      expect(toolResp?.display).toEqual(
        expect.objectContaining({
          name: 'read_file',
          result: { type: 'text', text: 'file contents' },
        }),
      );
      expect(toolResp?.content).toEqual([
        { type: 'text', text: 'file contents' },
      ]);
      expect(toolResp?.isError).toBe(false);

      // Should have called sendMessageStream twice
      expect(sendMock).toHaveBeenCalledTimes(2);
    });

    it('handles tool errors and sends error message in content', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'write_file'),
          },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );
      sendMock.mockReturnValueOnce(
        makeStream([
          { type: GeminiEventType.Content, value: 'Failed' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const errorToolCall: CompletedToolCall = {
        status: CoreToolCallStatus.Error,
        request: makeToolRequest('call-1', 'write_file'),
        response: {
          callId: 'call-1',
          responseParts: [{ text: 'stale' }],
          resultDisplay: 'Error display',
          error: new Error('Permission denied'),
          errorType: 'permission_denied',
        },
      } as CompletedToolCall;

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([errorToolCall]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('write file'));
      const events = await collectEvents(session);

      const toolResp = events.find(
        (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
      );
      expect(toolResp?.isError).toBe(true);
      // Uses error.message, not responseParts
      expect(toolResp?.content).toEqual([
        { type: 'text', text: 'Permission denied' },
      ]);
      expect(toolResp?.display?.result).toEqual({
        type: 'text',
        text: 'Error display',
      });
    });

    it('stops on STOP_EXECUTION tool error', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'dangerous_tool'),
          },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const stopToolCall: CompletedToolCall = {
        status: CoreToolCallStatus.Error,
        request: makeToolRequest('call-1', 'dangerous_tool'),
        response: {
          callId: 'call-1',
          responseParts: [],
          resultDisplay: undefined,
          error: new Error('Stopped by policy'),
          errorType: ToolErrorType.STOP_EXECUTION,
        },
      } as CompletedToolCall;

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([stopToolCall]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('do something'));
      const events = await collectEvents(session);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('completed');
      // Should NOT make a second call
      expect(sendMock).toHaveBeenCalledTimes(1);
    });

    it('treats fatal tool errors as tool_response followed by agent_end failed', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'write_file'),
          },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const fatalToolCall: CompletedToolCall = {
        status: CoreToolCallStatus.Error,
        request: makeToolRequest('call-1', 'write_file'),
        response: {
          callId: 'call-1',
          responseParts: [],
          resultDisplay: undefined,
          error: new Error('Disk full'),
          errorType: ToolErrorType.NO_SPACE_LEFT,
        },
      } as CompletedToolCall;

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([fatalToolCall]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('write file'));
      const events = await collectEvents(session);

      const toolResp = events.find(
        (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
      );
      expect(toolResp?.isError).toBe(true);
      expect(toolResp?.content).toEqual([{ type: 'text', text: 'Disk full' }]);
      expect(
        events.some(
          (e): e is AgentEvent<'error'> =>
            e.type === 'error' && e.fatal === true,
        ),
      ).toBe(false);

      const streamEnd = events.findLast(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('failed');
      expect(sendMock).toHaveBeenCalledTimes(1);
    });
  });

  describe('stream - terminal events', () => {
    it('handles AgentExecutionStopped', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.AgentExecutionStopped,
            value: { reason: 'hook', systemMessage: 'Halted by hook' },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('completed');
      expect(streamEnd?.data).toEqual({ message: 'Halted by hook' });
    });

    it('handles AgentExecutionBlocked as non-terminal and continues the stream', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.AgentExecutionBlocked,
            value: { reason: 'Blocked by hook' },
          },
          { type: GeminiEventType.Content, value: 'Final answer' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const blocked = events.find(
        (e): e is AgentEvent<'error'> =>
          e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED',
      );
      expect(blocked?.fatal).toBe(false);
      expect(blocked?.message).toBe('Blocked by hook');

      const messages = events.filter(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' && e.role === 'agent',
      );
      expect(
        messages.some(
          (message) =>
            message.content[0]?.type === 'text' &&
            message.content[0].text === 'Final answer',
        ),
      ).toBe(true);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('completed');
    });

    it('handles Error events', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.Error,
            value: { error: new Error('API error') },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?.message).toBe('API error');
      expect(events.some((e) => e.type === 'agent_end')).toBe(true);
    });

    it('handles LoopDetected as non-terminal warning event', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      // LoopDetected followed by more content — stream continues
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.LoopDetected },
          { type: GeminiEventType.Content, value: 'continuing after loop' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const warning = events.find(
        (e): e is AgentEvent<'error'> =>
          e.type === 'error' && e._meta?.['code'] === 'LOOP_DETECTED',
      );
      expect(warning).toBeDefined();
      expect(warning?.fatal).toBe(false);

      // Stream should have continued — content after loop detected
      const messages = events.filter(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' && e.role === 'agent',
      );
      expect(
        messages.some(
          (m) =>
            m.content[0]?.type === 'text' &&
            m.content[0].text === 'continuing after loop',
        ),
      ).toBe(true);

      // Should still end with agent_end completed
      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('completed');
    });
  });

  describe('stream - max turns', () => {
    it('emits agent_end with max_turns when the session turn limit is exceeded', async () => {
      const configMock = deps.config.getMaxSessionTurns as ReturnType<
        typeof vi.fn
      >;
      configMock.mockReturnValue(0);

      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'should not be reached' },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('max_turns');
      expect(streamEnd?.data).toEqual({
        code: 'MAX_TURNS_EXCEEDED',
        maxTurns: 0,
        turnCount: 0,
      });
      expect(sendMock).not.toHaveBeenCalled();
    });

    it('treats GeminiClient MaxSessionTurns as a terminal max_turns stream end', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([{ type: GeminiEventType.MaxSessionTurns }]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const errorEvents = events.filter(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(errorEvents).toHaveLength(0);

      const streamEnd = events.findLast(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('max_turns');
      expect(streamEnd?.data).toEqual({
        code: 'MAX_TURNS_EXCEEDED',
      });
    });
  });

  describe('abort', () => {
    it('treats abort before the first model event as aborted without fatal error', async () => {
      let releaseAbort: (() => void) | undefined;
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        (async function* () {
          await new Promise<void>((resolve) => {
            releaseAbort = resolve;
          });
          yield* [];
          const abortError = new Error('Aborted');
          abortError.name = 'AbortError';
          throw abortError;
        })(),
      );

      const session = new LegacyAgentSession(deps);
      const { streamId } = await session.send(makeMessageSend('hi'));
      await vi.advanceTimersByTimeAsync(0);

      await session.abort();
      releaseAbort?.();

      const events = await collectEvents(session, {
        streamId: streamId ?? undefined,
      });
      expect(
        events.some(
          (event): event is AgentEvent<'error'> =>
            event.type === 'error' && event.fatal,
        ),
      ).toBe(false);

      const streamEnd = events.findLast(
        (event): event is AgentEvent<'agent_end'> => event.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('aborted');
    });

    it('aborts the stream', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      // Stream that yields content then checks abort signal via a deferred
      let resolveHang: (() => void) | undefined;
      sendMock.mockReturnValue(
        (async function* () {
          yield {
            type: GeminiEventType.Content,
            value: 'start',
          } as ServerGeminiStreamEvent;
          // Wait until externally resolved (by abort)
          await new Promise<void>((resolve) => {
            resolveHang = resolve;
          });
          yield {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          } as ServerGeminiStreamEvent;
        })(),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));

      // Give the loop time to start processing
      await new Promise((r) => setTimeout(r, 50));

      // Abort and resolve the hang so the generator can finish
      await session.abort();
      resolveHang?.();

      // Collect all events
      const events = await collectEvents(session);

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('aborted');
    });

    it('treats abort during pending scheduler work as aborted without fatal error', async () => {
      let resolveSchedule: ((value: CompletedToolCall[]) => void) | undefined;
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'slow_tool'),
          },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockReturnValue(
        new Promise<CompletedToolCall[]>((resolve) => {
          resolveSchedule = resolve;
        }),
      );

      const session = new LegacyAgentSession(deps);
      const { streamId } = await session.send(makeMessageSend('hi'));

      await new Promise((resolve) => setTimeout(resolve, 25));
      await session.abort();
      resolveSchedule?.([makeCompletedToolCall('call-1', 'slow_tool', 'done')]);

      const events = await collectEvents(session, {
        streamId: streamId ?? undefined,
      });
      expect(
        events.some(
          (event): event is AgentEvent<'error'> =>
            event.type === 'error' && event.fatal,
        ),
      ).toBe(false);
      expect(events.some((event) => event.type === 'tool_response')).toBe(
        false,
      );

      const streamEnd = events.findLast(
        (event): event is AgentEvent<'agent_end'> => event.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('aborted');
    });
  });

  describe('events property', () => {
    it('accumulates all events', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'hi' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      await collectEvents(session);

      expect(session.events.length).toBeGreaterThan(0);
      expect(session.events[0]?.type).toBe('message');
    });
  });

  describe('subscription and stream scoping', () => {
    it('subscribe receives live events for the next stream', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'hello later' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      const liveEvents: AgentEvent[] = [];
      const unsubscribe = session.subscribe((event) => {
        liveEvents.push(event);
      });

      const { streamId } = await session.send(makeMessageSend('hi'));
      await collectEvents(session, { streamId: streamId ?? undefined });
      unsubscribe();

      expect(liveEvents.length).toBeGreaterThan(0);
      expect(liveEvents[0]?.type).toBe('message');
      expect(liveEvents.every((event) => event.streamId === streamId)).toBe(
        true,
      );
    });

    it('subscribe is live-only and does not replay old history when idle', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'first answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        )
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'second answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        );

      const session = new LegacyAgentSession(deps);
      const first = await session.send(makeMessageSend('first request'));
      await collectEvents(session, { streamId: first.streamId ?? undefined });

      const liveEvents: AgentEvent[] = [];
      const unsubscribe = session.subscribe((event) => {
        liveEvents.push(event);
      });

      const second = await session.send(makeMessageSend('second request'));
      await collectEvents(session, { streamId: second.streamId ?? undefined });
      unsubscribe();

      expect(liveEvents.length).toBeGreaterThan(0);
      expect(
        liveEvents.every((event) => event.streamId === second.streamId),
      ).toBe(true);
      expect(
        liveEvents.some(
          (event) =>
            event.type === 'message' &&
            event.role === 'user' &&
            event.content[0]?.type === 'text' &&
            event.content[0].text === 'first request',
        ),
      ).toBe(false);
    });

    it('streams only the requested streamId', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'first answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        )
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'second answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        );

      const session = new LegacyAgentSession(deps);
      const first = await session.send(makeMessageSend('first request'));
      await collectEvents(session, { streamId: first.streamId ?? undefined });

      const second = await session.send(makeMessageSend('second request'));
      await collectEvents(session, { streamId: second.streamId ?? undefined });

      const firstStreamEvents = await collectEvents(session, {
        streamId: first.streamId ?? undefined,
      });

      expect(
        firstStreamEvents.every((event) => event.streamId === first.streamId),
      ).toBe(true);
      expect(
        firstStreamEvents.some(
          (e) =>
            e.type === 'message' &&
            e.role === 'agent' &&
            e.content[0]?.type === 'text' &&
            e.content[0].text === 'first answer',
        ),
      ).toBe(true);
      expect(
        firstStreamEvents.some(
          (e) =>
            e.type === 'message' &&
            e.role === 'agent' &&
            e.content[0]?.type === 'text' &&
            e.content[0].text === 'second answer',
        ),
      ).toBe(false);
    });

    it('resumes from eventId within the same stream only', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'first answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        )
        .mockReturnValueOnce(
          makeStream([
            { type: GeminiEventType.Content, value: 'second answer' },
            {
              type: GeminiEventType.Finished,
              value: { reason: FinishReason.STOP, usageMetadata: undefined },
            },
          ]),
        );

      const session = new LegacyAgentSession(deps);
      const first = await session.send(makeMessageSend('first request'));
      await collectEvents(session, { streamId: first.streamId ?? undefined });

      await session.send(makeMessageSend('second request'));
      await collectEvents(session);

      const firstAgentMessage = session.events.find(
        (e): e is AgentEvent<'message'> =>
          e.type === 'message' &&
          e.role === 'agent' &&
          e.streamId === first.streamId &&
          e.content[0]?.type === 'text' &&
          e.content[0].text === 'first answer',
      );
      expect(firstAgentMessage).toBeDefined();

      const resumedEvents = await collectEvents(session, {
        eventId: firstAgentMessage?.id,
      });
      expect(
        resumedEvents.every((event) => event.streamId === first.streamId),
      ).toBe(true);
      expect(resumedEvents.map((event) => event.type)).toEqual(['agent_end']);
      expect(
        resumedEvents.some(
          (e) =>
            e.type === 'message' &&
            e.role === 'agent' &&
            e.content[0]?.type === 'text' &&
            e.content[0].text === 'second answer',
        ),
      ).toBe(false);
    });
  });

  describe('agent_end ordering', () => {
    it('agent_end is always the final event yielded', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          { type: GeminiEventType.Content, value: 'Hello' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      expect(events.length).toBeGreaterThan(0);
      expect(events[events.length - 1]?.type).toBe('agent_end');
    });

    it('agent_end is final even after error events', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValue(
        makeStream([
          {
            type: GeminiEventType.Error,
            value: { error: new Error('API error') },
          },
        ]),
      );

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      expect(events[events.length - 1]?.type).toBe('agent_end');
    });
  });

  describe('intermediate Finished events', () => {
    it('does NOT emit agent_end when tool calls are pending', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      // First turn: tool request + Finished (should NOT produce agent_end)
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'read_file'),
          },
          {
            type: GeminiEventType.Finished,
            value: {
              reason: FinishReason.STOP,
              usageMetadata: {
                promptTokenCount: 50,
                candidatesTokenCount: 20,
              },
            },
          },
        ]),
      );
      // Second turn: final answer
      sendMock.mockReturnValueOnce(
        makeStream([
          { type: GeminiEventType.Content, value: 'Answer' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([
        makeCompletedToolCall('call-1', 'read_file', 'data'),
      ]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('do it'));
      const events = await collectEvents(session);

      // Only one agent_end at the very end
      const streamEnds = events.filter((e) => e.type === 'agent_end');
      expect(streamEnds).toHaveLength(1);
      expect(streamEnds[0]).toBe(events[events.length - 1]);
    });

    it('emits usage for intermediate Finished events', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockReturnValueOnce(
        makeStream([
          {
            type: GeminiEventType.ToolCallRequest,
            value: makeToolRequest('call-1', 'read_file'),
          },
          {
            type: GeminiEventType.Finished,
            value: {
              reason: FinishReason.STOP,
              usageMetadata: {
                promptTokenCount: 100,
                candidatesTokenCount: 30,
              },
            },
          },
        ]),
      );
      sendMock.mockReturnValueOnce(
        makeStream([
          { type: GeminiEventType.Content, value: 'Done' },
          {
            type: GeminiEventType.Finished,
            value: { reason: FinishReason.STOP, usageMetadata: undefined },
          },
        ]),
      );

      const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
      scheduleMock.mockResolvedValueOnce([
        makeCompletedToolCall('call-1', 'read_file', 'contents'),
      ]);

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('go'));
      const events = await collectEvents(session);

      // Should have at least one usage event from the intermediate Finished
      const usageEvents = events.filter(
        (e): e is AgentEvent<'usage'> => e.type === 'usage',
      );
      expect(usageEvents.length).toBeGreaterThanOrEqual(1);
      expect(usageEvents[0]?.inputTokens).toBe(100);
      expect(usageEvents[0]?.outputTokens).toBe(30);
    });
  });

  describe('error handling in runLoop', () => {
    it('catches thrown errors and emits error + agent_end', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      sendMock.mockImplementation(() => {
        throw new Error('Connection refused');
      });

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?.message).toBe('Connection refused');
      expect(err?.fatal).toBe(true);
      expect(err?._meta?.['stack']).toBeDefined();

      const streamEnd = events.find(
        (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
      );
      expect(streamEnd?.reason).toBe('failed');
    });
  });

  describe('_emitErrorAndAgentEnd metadata', () => {
    it('preserves exitCode and code in _meta for FatalError', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      // Simulate a FatalError being thrown
      const { FatalError } = await import('../utils/errors.js');
      sendMock.mockImplementation(() => {
        throw new FatalError('Disk full', 44);
      });

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?.message).toBe('Disk full');
      expect(err?.fatal).toBe(true);
      expect(err?._meta?.['exitCode']).toBe(44);
      expect(err?._meta?.['errorName']).toBe('FatalError');
    });

    it('preserves exitCode for non-FatalError errors that carry one', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      const exitCodeError = new Error('custom exit');
      (exitCodeError as Error & { exitCode: number }).exitCode = 17;
      sendMock.mockImplementation(() => {
        throw exitCodeError;
      });

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?._meta?.['exitCode']).toBe(17);
    });

    it('preserves code in _meta for errors with code property', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      const codedError = new Error('ENOENT');
      (codedError as Error & { code: string }).code = 'ENOENT';
      sendMock.mockImplementation(() => {
        throw codedError;
      });

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?._meta?.['code']).toBe('ENOENT');
    });

    it('preserves status in _meta for errors with status property', async () => {
      const sendMock = deps.client.sendMessageStream as ReturnType<
        typeof vi.fn
      >;
      const statusError = new Error('rate limited');
      (statusError as Error & { status: string }).status = 'RESOURCE_EXHAUSTED';
      sendMock.mockImplementation(() => {
        throw statusError;
      });

      const session = new LegacyAgentSession(deps);
      await session.send(makeMessageSend('hi'));
      const events = await collectEvents(session);

      const err = events.find(
        (e): e is AgentEvent<'error'> => e.type === 'error',
      );
      expect(err?._meta?.['status']).toBe('RESOURCE_EXHAUSTED');
    });
  });
});
