import type { ConnectionOperationTarget } from '@hermes/shared/gateway-events'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import {
  $connectionOperation,
  dismissConnectionOperation,
  resetConnectionOperationsForTests
} from '../app/connectionOperationStore.js'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { createServerRequestHandler } from '../app/createServerRequestHandler.js'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { resetServerRequestsForTests } from '../app/serverRequestStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import { ZERO } from '../domain/usage.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'

// Mock the external-URL opener so the billing.step_up.verification test can
// assert it's invoked without spawning a real browser process.
const openExternalUrlMock = vi.fn((_url: string) => true)
vi.mock('../lib/openExternalUrl.js', () => ({
  openExternalUrl: (url: string) => openExternalUrlMock(url)
}))

const ref = <T>(current: T) => ({ current })

const buildCtx = (appended: Msg[]) =>
  ({
    composer: {
      dequeue: () => undefined,
      queueEditRef: ref<null | number>(null),
      sendQueued: vi.fn(),
      setInput: vi.fn()
    },
    gateway: {
      gw: { request: vi.fn(async () => null) },
      rpc: vi.fn(async () => null)
    },
    session: {
      STARTUP_RESUME_ID: '',
      colsRef: ref(80),
      newSession: vi.fn(),
      resetSession: vi.fn(),
      resumeById: vi.fn(),
      setCatalog: vi.fn()
    },
    submission: {
      submitRef: { current: vi.fn() }
    },
    system: {
      bellOnComplete: false,
      sys: vi.fn()
    },
    transcript: {
      appendMessage: (msg: Msg) => appended.push(msg),
      panel: (title: string, sections: any[]) =>
        appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
      setHistoryItems: vi.fn()
    },
    voice: {
      setProcessing: vi.fn(),
      setRecording: vi.fn(),
      setVoiceEnabled: vi.fn()
    }
  }) as any

/** Deliver one server→client request (`tui_gateway/server_requests.py`) to the TUI's request handler. */
const serverRequest = (method: string, params: Record<string, unknown>, id = `srq-${method}`) => {
  const respond = vi.fn()

  const handled = createServerRequestHandler({
    ringPromptBell: vi.fn(),
    setStatus: status => patchUiState({ status })
  })({
    fail: vi.fn(),
    id,
    method,
    params,
    respond
  })

  return { handled, respond }
}

describe('createGatewayEventHandler', () => {
  beforeEach(() => {
    resetOverlayState()
    resetUiState()
    resetTurnState()
    resetServerRequestsForTests()
    resetConnectionOperationsForTests()
    turnController.fullReset()
    patchUiState({ showReasoning: true })
  })

  it('displays generic errors without settling versioned execution', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)
    patchUiState({
      sid: 'owner',
      busy: true,
      status: 'running…',
      info: { model: 'test', tools: {}, skills: {}, execution_epoch: 'owner-epoch', execution_generation: 2 }
    })
    onEvent({ type: 'error', session_id: 'owner', payload: { message: 'build failed' } } as any)
    expect(ctx.system.sys).toHaveBeenCalledWith('error: build failed')
    expect(getUiState()).toMatchObject({ busy: true, status: 'running…' })
    onEvent({
      type: 'error',
      session_id: 'owner',
      payload: { message: 'turn failed', execution_epoch: 'owner-epoch', execution_generation: 2 }
    } as any)
    expect(ctx.system.sys).toHaveBeenCalledWith('error: turn failed')
    expect(getUiState().busy).toBe(false)
  })

  it('an idle snapshot a frame ahead of the final leaves the tool trail for the final to archive', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    patchUiState({ sid: 'focused', info: { model: 'test', skills: {}, tools: {} } as any })
    const emit = (type: string, payload: any) => onEvent({ type, payload, session_id: 'focused' } as any)

    emit('message.start', {})
    emit('tool.start', { tool_id: 'c1', name: 'terminal', context: 'echo TOOLMARK' })
    emit('tool.complete', { tool_id: 'c1', name: 'terminal', duration_s: 0.1 })
    // The authority settles the row (running: false) before it publishes message.complete.
    emit('session.info', { running: false })
    expect(getUiState().busy).toBe(false)
    emit('message.complete', { text: 'BETA after the tool' })

    const trail = appended.find(m => m.kind === 'trail')
    expect(trail?.tools?.join('\n')).toContain('TOOLMARK')
    expect(appended.at(-1)).toMatchObject({ role: 'assistant', text: 'BETA after the tool' })
    // Nothing is left parked for the next turn.
    emit('message.start', {})
    expect(getTurnState().streamSegments).toEqual([])
  })

  it('fences restarted owner lifecycle events until resume establishes the new epoch', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    const info = {
      model: 'test',
      skills: {},
      tools: {},
      running: true,
      execution_epoch: 'old-owner',
      execution_generation: 9
    }

    patchUiState({ sid: 'focused', info, busy: true })
    const emit = (type: string, payload: any) => onEvent({ type, payload, session_id: 'focused' } as any)
    emit('session.info', { execution_epoch: 'new-owner', execution_generation: 1, running: false })
    expect(getUiState().busy).toBe(true)
    // session.resume replaces info with the authoritative attachment snapshot.
    patchUiState({ info: { ...info, execution_epoch: 'new-owner', execution_generation: 1 } })

    for (const payload of [
      {},
      { execution_epoch: 'old-owner', execution_generation: 99 },
      { execution_epoch: 'new-owner', execution_generation: 0 }
    ]) {
      emit('message.complete', { ...payload, text: 'stale' })
      emit('error', { ...payload, message: 'stale' })
      emit('session.info', { ...payload, running: false })
      expect(getUiState().busy).toBe(true)
    }

    expect(appended).toEqual([])
    emit('message.complete', { execution_epoch: 'new-owner', execution_generation: 1, text: 'fresh' })
    expect(getUiState().busy).toBe(false)
    expect(appended.some(m => m.text === 'fresh')).toBe(true)
    emit('message.start', { execution_epoch: 'new-owner', execution_generation: 2 })
    emit('message.complete', { execution_epoch: 'new-owner', execution_generation: 1, text: 'late' })
    expect(getUiState().busy).toBe(true)
    expect(getUiState().info?.execution_generation).toBe(2)
    emit('message.complete', { execution_epoch: 'new-owner', execution_generation: 4, text: 'missed start' })
    emit('message.start', { execution_epoch: 'new-owner', execution_generation: 3 })
    expect(getUiState().busy).toBe(false)
    expect(getUiState().info?.execution_generation).toBe(4)
  })

  it('heals missed completion and blocking prompts only from the focused authoritative idle snapshot', () => {
    patchUiState({ sid: 'focused' })
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)
    onEvent({ session_id: 'focused', payload: {}, type: 'message.start' } as any)
    serverRequest('approval', { session_id: 'focused', request_id: 'approval', command: 'test' })
    const busyOverlay = getOverlayState().approval
    expect(getUiState().busy).toBe(true)
    expect(busyOverlay).not.toBeNull()
    const snapshot = { model: 'test', skills: {}, tools: {} }
    onEvent({ session_id: 'other', payload: { ...snapshot, running: false }, type: 'session.info' } as any)
    onEvent({ session_id: 'focused', payload: snapshot, type: 'session.info' } as any)
    onEvent({ session_id: 'focused', payload: { ...snapshot, running: true }, type: 'session.info' } as any)
    expect(getUiState().busy).toBe(true)
    expect(getOverlayState().approval).toEqual(busyOverlay)
    onEvent({ session_id: 'focused', payload: { ...snapshot, running: false }, type: 'session.info' } as any)
    expect(getUiState().busy).toBe(false)
    expect(getUiState().status).toBe('ready')
    expect(getOverlayState().approval).toBeNull()
    expect(getTurnState().tools).toEqual([])
    onEvent({
      session_id: 'focused',
      payload: { ...snapshot, running: true, execution_generation: 2 },
      type: 'session.info'
    } as any)
    onEvent({
      session_id: 'focused',
      payload: { running: false, execution_generation: 1 },
      type: 'session.info'
    } as any)
    expect(getUiState().busy).toBe(true)
    onEvent({
      session_id: 'focused',
      payload: { running: false, execution_generation: 2 },
      type: 'session.info'
    } as any)
    expect(getUiState().busy).toBe(false)
    expect(getUiState().info?.model).toBe('test')

    const target: ConnectionOperationTarget = { action: 'install', kind: 'mcp', name: 'asana', state: 'pending' }
    onEvent({
      session_id: 'focused',
      payload: {
        deadline_at: 10,
        op_id: 'op-1',
        seq: 2,
        targets: [target],
        timeout_seconds: 30
      },
      type: 'connection.request'
    })
    expect($connectionOperation.get()).toMatchObject({ opId: 'op-1', seq: 2, targets: [target] })
    expect(getOverlayState().connection).toEqual({ opId: 'op-1' })

    onEvent({
      session_id: 'focused',
      payload: {
        deadline_at: 11,
        op_id: 'op-1',
        seq: 1,
        settled: false,
        targets: [{ ...target, state: 'failed' }]
      },
      type: 'connection.update'
    })
    expect($connectionOperation.get()).toMatchObject({ seq: 2, targets: [target] })

    // Esc on the "Finishing…" card drops it and it must not come back on a replay, but the settling
    // frame that follows still records how each app ended.
    const request = {
      deadline_at: 10,
      op_id: 'op-1',
      seq: 2,
      targets: [target],
      timeout_seconds: 30
    }

    dismissConnectionOperation('op-1')
    expect($connectionOperation.get()).toBeNull()
    onEvent({ session_id: 'focused', payload: request, type: 'connection.request' })
    expect($connectionOperation.get()).toBeNull()
    expect(getOverlayState().connection).toBeNull()

    onEvent({
      session_id: 'focused',
      payload: {
        deadline_at: 12,
        op_id: 'op-1',
        seq: 3,
        settled: true,
        targets: [{ ...target, state: 'connected' }]
      },
      type: 'connection.update'
    })
    expect(ctx.system.sys.mock.calls.map((call: unknown[]) => call[0])).toEqual(['asana: connected'])
  })

  it('keeps the durable session id when a session.info payload omits it', () => {
    patchUiState({ sid: 'focused', storedSid: 'durable-1' })
    const onEvent = createGatewayEventHandler(buildCtx([]))

    // Agent-less producers (_fallback_session_info, lazy cwd switch) send no stored_session_id.
    onEvent({
      session_id: 'focused',
      payload: { cwd: '/tmp/a', model: 'test', skills: {}, tools: {} },
      type: 'session.info'
    } as any)
    expect(getUiState().storedSid).toBe('durable-1')
    expect(getUiState().info?.stored_session_id).toBe('durable-1')

    // A payload that carries one is authoritative.
    onEvent({
      session_id: 'focused',
      payload: { model: 'test', skills: {}, stored_session_id: 'durable-2', tools: {} },
      type: 'session.info'
    } as any)
    expect(getUiState().storedSid).toBe('durable-2')
    expect(getUiState().info?.stored_session_id).toBe('durable-2')
  })

  it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => {
    const appended: Msg[] = []

    const todos = [
      { content: 'Gather ingredients', id: 'prep', status: 'completed' },
      { content: 'Boil water', id: 'boil', status: 'in_progress' },
      { content: 'Make sauce', id: 'sauce', status: 'pending' }
    ]

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any)
    expect(getTurnState().todos).toEqual(todos)

    onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any)

    const trail = appended.find(msg => msg.kind === 'trail' && msg.todos?.length)
    const finalText = appended.find(msg => msg.role === 'assistant' && msg.text === 'Started a todo list.')

    expect(finalText).toBeDefined()
    expect(trail).toMatchObject({ kind: 'trail', role: 'system', todos, todoIncomplete: true })
    // Todo archive must sit ABOVE the final assistant text so the panel
    // doesn't visibly jump across the final answer at end-of-turn.
    expect(appended.indexOf(trail!)).toBeLessThan(appended.indexOf(finalText!))
    expect(getTurnState().todos).toEqual([])
  })

  it('opens a billing confirm dialog routing Nous to /topup', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({
      payload: {
        billing: {
          billing_url: null,
          is_nous: true,
          message: 'out of credits',
          model: 'm',
          provider: 'nous',
          provider_label: 'Nous Portal'
        },
        text: 'Billing or credits exhausted: ...'
      },
      type: 'message.complete'
    } as any)

    const { confirm } = getOverlayState()

    confirm!.onConfirm()
    expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('/topup')
  })

  it('deep-links a third-party provider billing page from the confirm dialog', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)
    openExternalUrlMock.mockClear()

    onEvent({
      payload: {
        billing: {
          billing_url: 'https://openrouter.ai/settings/credits',
          is_nous: false,
          message: 'out of credits',
          model: 'm',
          provider: 'openrouter',
          provider_label: 'OpenRouter'
        },
        text: 'Billing or credits exhausted: ...'
      },
      type: 'message.complete'
    } as any)

    const { confirm } = getOverlayState()

    confirm!.onConfirm()
    expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits')
  })

  it('archives completed todos into transcript flow at end of turn', () => {
    const appended: Msg[] = []
    const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }]
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)

    expect(getTurnState().todos).toEqual([])
    expect(appended).toContainEqual({
      kind: 'trail',
      role: 'system',
      text: '',
      todoCollapsedByDefault: true,
      todos
    })
  })

  it('keeps the current todo list visible when the next message starts', () => {
    const appended: Msg[] = []
    const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any)
    expect(getTurnState().todos).toEqual(todos)

    onEvent({ payload: {}, type: 'message.start' } as any)

    expect(getTurnState().todos).toEqual(todos)
  })

  it('prints compaction progress status into the transcript', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({
      payload: { kind: 'compressing', text: 'compressing 968 messages (~123,400 tok)…' },
      type: 'status.update'
    } as any)

    expect(ctx.system.sys).toHaveBeenCalledWith('compressing 968 messages (~123,400 tok)…')
    expect(getUiState().compacting).toBe(true)
  })

  it('keeps auto-compaction status visible until compaction finishes (#97239)', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)
    const idleLine = '💤 Resumed after 747s idle — compacting ~44,579 tokens before continuing.'

    vi.useFakeTimers()
    patchUiState({ busy: true, status: 'running…' })

    try {
      onEvent({
        payload: { kind: 'compacting', text: idleLine },
        type: 'status.update'
      } as any)

      expect(ctx.system.sys).toHaveBeenCalledWith(idleLine)
      expect(getUiState().compacting).toBe(true)
      expect(getUiState().status).toBe(idleLine)

      vi.advanceTimersByTime(4001)
      expect(getUiState().status).toBe(idleLine)
      expect(getUiState().compacting).toBe(true)

      onEvent({
        payload: { kind: 'compacted', text: '✓ Context compaction complete — continuing turn...' },
        type: 'status.update'
      } as any)

      expect(getUiState().compacting).toBe(false)
    } finally {
      vi.useRealTimers()
    }
  })

  it('keeps goal verdict text in transcript but shows a brief idle status (#goal statusbar)', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)
    const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.'

    vi.useFakeTimers()

    try {
      onEvent({
        payload: { kind: 'goal', text: verdict },
        type: 'status.update'
      } as any)

      expect(ctx.system.sys).toHaveBeenCalledWith(verdict)
      expect(getUiState().status).not.toBe(verdict)
      expect(getUiState().status.length).toBeLessThan(verdict.length)

      vi.advanceTimersByTime(6001)
      expect(getUiState().status).toBe('ready')
    } finally {
      vi.useRealTimers()
    }
  })

  it('surfaces self-improvement review summaries as a persistent system line', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({
      payload: { text: "💾 Self-improvement review: Skill 'hermes-release' patched" },
      type: 'review.summary'
    } as any)

    expect(ctx.system.sys).toHaveBeenCalledWith("💾 Self-improvement review: Skill 'hermes-release' patched")
  })

  it('ignores review.summary events with empty or missing text', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { text: '' }, type: 'review.summary' } as any)
    onEvent({ payload: { text: '   ' }, type: 'review.summary' } as any)
    onEvent({ payload: undefined, type: 'review.summary' } as any)

    expect(ctx.system.sys).not.toHaveBeenCalled()
  })

  it('clears the visible todo list when the todo tool returns an empty list', () => {
    const appended: Msg[] = []
    const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any)
    expect(getTurnState().todos).toEqual(todos)

    onEvent({ payload: { name: 'todo', todos: [], tool_id: 'todo-1' }, type: 'tool.complete' } as any)

    expect(getTurnState().todos).toEqual([])
  })

  it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
    const appended: Msg[] = []

    turnController.reasoningText = 'mapped the page'
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
      type: 'tool.start'
    } as any)
    onEvent({
      payload: { summary: 'done', tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)
    onEvent({
      payload: { text: 'final answer' },
      type: 'message.complete'
    } as any)

    expect(appended).toHaveLength(2)
    expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' })
    expect(appended[0]?.tools).toHaveLength(1)
    expect(appended[0]?.tools?.[0]).toContain('home page')
    expect(appended[0]?.toolTokens).toBeGreaterThan(0)
    expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
  })

  it('groups sequential completed tools into one trail when the turn completes', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { context: 'alpha', name: 'search_files', tool_id: 'tool-1' }, type: 'tool.start' } as any)
    onEvent({
      payload: { name: 'search_files', summary: 'first done', tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)
    onEvent({ payload: { context: 'beta', name: 'read_file', tool_id: 'tool-2' }, type: 'tool.start' } as any)
    onEvent({ payload: { name: 'read_file', summary: 'second done', tool_id: 'tool-2' }, type: 'tool.complete' } as any)

    expect(getTurnState().streamSegments.filter(msg => msg.kind === 'trail' && msg.tools?.length)).toHaveLength(1)
    expect(getTurnState().streamSegments[0]?.tools).toHaveLength(2)
    expect(getTurnState().streamPendingTools).toEqual([])

    onEvent({ payload: { text: '' }, type: 'message.complete' } as any)

    const toolTrails = appended.filter(msg => msg.kind === 'trail' && msg.tools?.length)
    expect(toolTrails).toHaveLength(1)
    expect(toolTrails[0]?.tools).toHaveLength(2)
  })

  it('keeps tool tokens across handler recreation mid-turn', () => {
    const appended: Msg[] = []

    turnController.reasoningText = 'mapped the page'

    createGatewayEventHandler(buildCtx(appended))({
      payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
      type: 'tool.start'
    } as any)

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { summary: 'done', tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)
    onEvent({
      payload: { text: 'final answer' },
      type: 'message.complete'
    } as any)

    expect(appended).toHaveLength(2)
    expect(appended[0]?.tools).toHaveLength(1)
    expect(appended[0]?.toolTokens).toBeGreaterThan(0)
    expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
  })

  it('streams legacy thinking.delta into visible reasoning state', () => {
    vi.useFakeTimers()
    const appended: Msg[] = []
    const streamed = 'short streamed reasoning'
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    try {
      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { text: streamed }, type: 'thinking.delta' } as any)
      vi.runOnlyPendingTimers()

      expect(getTurnState().reasoning).toBe(streamed)
      expect(getTurnState().reasoningActive).toBe(true)
      expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed))
    } finally {
      vi.useRealTimers()
    }
  })

  it('ignores late thinking.delta after the turn has already completed', () => {
    vi.useFakeTimers()
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    try {
      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
      expect(getUiState().busy).toBe(false)
      expect(getUiState().status).toBe('ready')

      onEvent({ payload: { text: 'thinking...' }, type: 'thinking.delta' } as any)
      vi.runOnlyPendingTimers()

      expect(getUiState().status).toBe('ready')
      expect(getTurnState().reasoning).toBe('')
    } finally {
      vi.useRealTimers()
    }
  })

  it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => {
    const appended: Msg[] = []
    const streamed = 'first reasoning chunk\nsecond reasoning chunk'

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
    onEvent({ payload: { text: 'Before edit.' }, type: 'message.delta' } as any)
    turnController.flushStreamingSegment()
    onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)

    expect(appended.map(msg => msg.thinking).filter(Boolean)).toEqual([streamed])
    expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
  })

  it('shows verbose reasoning even when normal reasoning display is off', () => {
    vi.useFakeTimers()
    patchUiState({ showReasoning: false })
    const appended: Msg[] = []
    const streamed = 'verbose-only reasoning'

    try {
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: { text: streamed, verbose: true }, type: 'reasoning.delta' } as any)
      vi.runOnlyPendingTimers()

      expect(turnController.reasoningText).toBe(streamed)
      expect(getTurnState().reasoning).toBe(streamed)
    } finally {
      vi.useRealTimers()
    }
  })

  it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
    const appended: Msg[] = []
    const streamed = 'short streamed reasoning'
    const fallback = 'x'.repeat(400)

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
    onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
    onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)

    expect(appended).toHaveLength(2)
    expect(appended[0]?.thinking).toBe(streamed)
    expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
    expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
  })

  it('renders moa.reference as a labelled thinking-style segment', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({
      payload: { count: 2, index: 1, label: 'openrouter:openai/gpt-5.5', text: 'Paris.' },
      type: 'moa.reference'
    } as any)
    onEvent({
      payload: { count: 2, index: 2, label: 'openrouter:anthropic/claude-opus-4.8', text: 'Paris.' },
      type: 'moa.reference'
    } as any)

    const segments = getTurnState().streamSegments
    const refBlocks = segments.filter(m => typeof m.thinking === 'string' && m.thinking.includes('Reference'))
    expect(refBlocks).toHaveLength(2)
    expect(refBlocks[0]?.thinking).toContain('openrouter:openai/gpt-5.5')
    expect(refBlocks[0]?.thinking).toContain('Paris.')
    expect(refBlocks[1]?.thinking).toContain('openrouter:anthropic/claude-opus-4.8')
  })

  it('renders moa.reference even when showReasoning is off (it is the MoA process, not reasoning)', () => {
    patchUiState({ showReasoning: false })
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({
      payload: { label: 'openrouter:openai/gpt-5.5', text: 'Four.' },
      type: 'moa.reference'
    } as any)

    const segments = getTurnState().streamSegments
    const refBlocks = segments.filter(m => typeof m.thinking === 'string' && m.thinking.includes('Reference'))
    expect(refBlocks).toHaveLength(1)
    expect(refBlocks[0]?.thinking).toContain('openrouter:openai/gpt-5.5')
  })

  it('uses message.complete reasoning when no streamed reasoning ref', () => {
    const appended: Msg[] = []
    const fromServer = 'recovered from last_reasoning'

    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)

    expect(appended).toHaveLength(2)
    expect(appended[0]?.thinking).toBe(fromServer)
    expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
    expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
  })

  it('renders browser.progress events as system transcript lines as they stream in', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    const handler = createGatewayEventHandler(ctx)

    handler({
      payload: { message: 'Chromium-family browser launched and listening on port 9222' },
      type: 'browser.progress'
    } as any)

    expect(ctx.system.sys).toHaveBeenCalledWith('Chromium-family browser launched and listening on port 9222')
  })

  it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: {
        cwd: '/repo',
        python: '/opt/venv/bin/python',
        stderr_tail:
          '[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml'
      },
      type: 'gateway.start_timeout'
    } as any)

    const messages = getTurnState().activity.map(a => a.text)

    // Says it is still waiting and where to look — never the interpreter path or cwd.
    expect(messages.some(m => m.includes('/logs'))).toBe(true)
    expect(messages.some(m => m.includes('/opt/venv/bin/python') || m.includes('/repo'))).toBe(false)
    // Failure-looking stderr lines are echoed inline; bookkeeping lines are not.
    expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
    expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
    expect(messages.some(m => m.includes('[startup] timed out'))).toBe(false)
  })

  it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const raw = 'Hermes here.\n\nLine two.'
    // Rich-rendered ANSI (`final_response_markdown: render`) used to win,
    // which left visible escape codes in Ink output. Raw text must win.
    const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'

    onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)

    const assistant = appended.find(msg => msg.role === 'assistant')
    expect(assistant?.text).toBe(raw)
    expect(assistant?.text).not.toContain('\u001b[')
  })

  it('falls back to payload.rendered when text is missing on message.complete', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const rendered = 'fallback when gateway omitted text'

    onEvent({ payload: { rendered }, type: 'message.complete' } as any)

    const assistant = appended.find(msg => msg.role === 'assistant')
    expect(assistant?.text).toBe(rendered)
  })

  it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // Stream of partial text deltas; each delta carries an incremental
    // Rich-ANSI fragment.  Pre-fix code would replace the whole bufRef
    // with the latest fragment, dropping prior text.
    onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
    onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
    onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
    onEvent({ payload: {}, type: 'message.complete' } as any)

    const assistant = appended.find(msg => msg.role === 'assistant')
    expect(assistant?.text).toBe('First. second.')
  })

  // Narration → tool → tool-complete → more narration → message.complete with
  // its own `payload.text`. Nothing flushes the second narration block, so
  // before the fix message.complete cleared the buffer and the transcript lost
  // a block the user had already watched render.
  const streamTailTurn = (onEvent: ReturnType<typeof createGatewayEventHandler>) => {
    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({ payload: { text: 'Checking the config first.' }, type: 'message.delta' } as any)
    onEvent({ payload: { context: 'config.yaml', name: 'read_file', tool_id: 'tool-1' }, type: 'tool.start' } as any)
    onEvent({ payload: { name: 'read_file', summary: 'read', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: 'The provider block looks wrong.' }, type: 'message.delta' } as any)
  }

  it('keeps streaming text buffered after a tool call, in order, at message.complete (#61520)', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    streamTailTurn(onEvent)
    onEvent({ payload: { text: 'Final answer.' }, type: 'message.complete' } as any)

    expect(appended.filter(msg => msg.role === 'assistant').map(msg => msg.text)).toEqual([
      'Checking the config first.',
      'The provider block looks wrong.',
      'Final answer.'
    ])

    // The tail is flushed through the normal segment path, so the pending tool
    // shelf lands on it exactly once instead of being duplicated or dropped.
    const toolRows = appended.flatMap(msg => msg.tools ?? [])
    expect(toolRows).toHaveLength(1)
    expect(toolRows[0]).toContain('Read File')
  })

  it.each([
    ['final text equals the streamed tail', { text: 'Answer.' }],
    ['no final text (#16391 buffer fallback)', {}]
  ])('keeps the tool shelf above the answer when %s (#61520)', (_label, payload) => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({ payload: { text: 'Pre.' }, type: 'message.delta' } as any)
    onEvent({ payload: { context: 'config.yaml', name: 'read_file', tool_id: 'tool-1' }, type: 'tool.start' } as any)
    onEvent({ payload: { name: 'read_file', summary: 'read', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: 'Answer.' }, type: 'message.delta' } as any)
    onEvent({ payload, type: 'message.complete' } as any)

    const answerIdx = appended.findIndex(msg => msg.text === 'Answer.')
    const toolIdx = appended.findIndex(msg => (msg.tools ?? []).length > 0)

    expect(appended.filter(msg => msg.text === 'Answer.')).toHaveLength(1)
    expect(appended[answerIdx]?.tools ?? []).toHaveLength(0)
    expect(toolIdx).toBeGreaterThan(-1)
    expect(toolIdx).toBeLessThan(answerIdx)
    expect(answerIdx).toBe(appended.length - 1)
  })

  it('anchors inline_diff as its own segment where the edit happened', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
    const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
    const block = `\`\`\`diff\n${cleaned}\n\`\`\``

    // Narration → tool → tool-complete → more narration → message-complete.
    // The diff MUST land between the two narration segments, not tacked
    // onto the final one.
    onEvent({ payload: { text: 'Editing the file' }, type: 'message.delta' } as any)
    onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
    onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)

    // Diff is already committed to segmentMessages as its own segment.
    expect(appended).toHaveLength(0)
    expect(turnController.segmentMessages).toEqual([
      { role: 'assistant', text: 'Editing the file' },
      {
        kind: 'diff',
        role: 'assistant',
        text: block,
        tools: [expect.stringContaining('foo.ts')]
      }
    ])

    onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)

    expect(appended).toHaveLength(4)
    expect(appended[0]?.text).toBe('Editing the file')
    expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
    expect(appended[3]?.text).toBe('patch applied')
    expect(appended[3]?.text).not.toContain('```diff')
  })

  it('keeps verbose result text on inline_diff tool completions', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'

    onEvent({
      payload: { args_text: '{ "path": "foo.ts" }', context: 'foo.ts', name: 'patch', tool_id: 'tool-1' },
      type: 'tool.start'
    } as any)
    onEvent({
      payload: { inline_diff: diff, result_text: 'patched result', tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)

    expect(turnController.segmentMessages[0]).toMatchObject({ kind: 'diff' })
    expect(turnController.segmentMessages[0]?.tools?.[0]).toContain('{ "path": "foo.ts" }')
    expect(turnController.segmentMessages[0]?.tools?.[0]).toContain('patched result')
  })

  it('keeps full final responses from duplicating flushed pre-diff narration', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
    const block = `\`\`\`diff\n${diff}\n\`\`\``

    onEvent({ payload: { text: 'Before edit. ' }, type: 'message.delta' } as any)
    onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
    onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: 'After edit.' }, type: 'message.delta' } as any)
    onEvent({ payload: { text: 'Before edit. After edit.' }, type: 'message.complete' } as any)

    expect(appended.map(msg => msg.text.trim()).filter(Boolean)).toEqual(['Before edit.', block, 'After edit.'])
  })

  it('drops the diff segment when the final assistant text narrates the same diff', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
    const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``

    onEvent({ payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)

    // Only the final message — diff-only segment dropped so we don't
    // render two stacked copies of the same patch.
    expect(appended).toHaveLength(1)
    expect(appended[0]?.text).toBe(assistantText)
    expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
  })

  it('strips the CLI "┊ review diff" header from inline diff segments', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const raw = '  \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'

    onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
    onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)

    // Tool trail first, then diff segment (kind='diff'), then final narration.
    expect(appended).toHaveLength(2)
    expect(appended[0]?.kind).toBe('diff')
    expect(appended[0]?.text).not.toContain('┊ review diff')
    expect(appended[0]?.text).toContain('--- a/foo.ts')
    expect(appended[1]?.text).toBe('done')
  })

  it('drops the diff segment when assistant writes its own ```diff fence', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
    const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'

    onEvent({
      payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)
    onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)

    expect(appended).toHaveLength(1)
    expect(appended[0]?.text).toBe(assistantText)
    expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
  })

  it('keeps tool trail terse when inline_diff is present', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))
    const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'

    onEvent({
      payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
      type: 'tool.complete'
    } as any)
    onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)

    // Tool row is now placed before the diff, so telemetry does not render
    // below the patch that came from that tool.
    expect(appended).toHaveLength(2)
    expect(appended[0]?.kind).toBe('diff')
    expect(appended[0]?.text).toContain('```diff')
    expect(appended[0]?.tools).toHaveLength(1)
    expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
    expect(appended[1]?.text).toBe('done')
    expect(appended[1]?.tools ?? []).toEqual([])
  })

  it('shows setup panel for missing provider startup error', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: {
        message:
          'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.'
      },
      type: 'error'
    } as any)

    expect(appended).toHaveLength(1)
    expect(appended[0]).toMatchObject({ kind: 'panel', role: 'system' })
  })

  it('does not fetch config while constructing the gateway event handler', () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)

    ctx.gateway.rpc = vi.fn(async () => null)

    createGatewayEventHandler(ctx)

    expect(ctx.gateway.rpc).not.toHaveBeenCalled()
  })

  it('picks the polarity-matching paired palette from gateway.ready skins', async () => {
    const appended: Msg[] = []

    const skin = {
      colors: { banner_title: '#00FF88', banner_text: '#FFF8DC' },
      light_colors: { banner_title: '#8B0000', banner_text: '#22201C' }
    }

    // Dark terminal (clean env): the dark-authored `colors` block wins.
    vi.stubEnv('HERMES_TUI_BACKGROUND', '')
    createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
    expect(getUiState().theme.color.primary).toBe('#00FF88')

    // Light terminal: the hand-tuned light_colors block wins over adaptation.
    vi.stubEnv('HERMES_TUI_BACKGROUND', '#ffffff')
    createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
    expect(getUiState().theme.color.primary).toBe('#8B0000')
    vi.unstubAllEnvs()
  })

  it('a skin that owns the background paints BOTH terminal defaults (OSC 11 bg + OSC 10 fg)', () => {
    // Default-fg tokens (markdown body, borders) render with the TERMINAL's
    // default foreground. A dark skin on a light terminal repaints the
    // backdrop black via OSC-11 — without the OSC-10 pair, those tokens stay
    // the host's near-black: invisible. The invariant is fg == theme text.
    const writes: string[] = []

    const write = vi.spyOn(process.stdout, 'write').mockImplementation(chunk => {
      writes.push(String(chunk))

      return true
    })

    const tty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
    Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true })

    try {
      const handle = createGatewayEventHandler(buildCtx([]))
      handle({ payload: { colors: { background: '#000000', ui_text: '#ff9f0a' } }, type: 'skin.changed' } as any)

      const joined = writes.join('')
      expect(joined).toContain('\x1b]11;#000000\x07')
      expect(joined).toContain(`\x1b]10;${getUiState().theme.color.text}\x07`)

      // Dropping the background releases BOTH defaults back to the terminal.
      writes.length = 0
      handle({ payload: { colors: { ui_text: '#ff9f0a' } }, type: 'skin.changed' } as any)
      expect(writes.join('')).toContain('\x1b]111\x07')
      expect(writes.join('')).toContain('\x1b]110\x07')
    } finally {
      write.mockRestore()

      if (tty) {
        Object.defineProperty(process.stdout, 'isTTY', tty)
      }
    }
  })

  it('infers polarity from the OSC-10 foreground only when the answer is decisive', async () => {
    const { polarityBackgroundFromForeground } = await import('../app/createGatewayEventHandler.js')

    // Bright foreground = dark theme; dark foreground = light theme.
    expect(polarityBackgroundFromForeground('#cccccc')).toBe('#1e1e1e')
    expect(polarityBackgroundFromForeground('#333333')).toBe('#ffffff')

    // Unset-default fingerprints and ambiguous mid-grays commit nothing.
    expect(polarityBackgroundFromForeground('#000000')).toBeUndefined()
    expect(polarityBackgroundFromForeground('#ffffff')).toBeUndefined()
    expect(polarityBackgroundFromForeground('#808080')).toBeUndefined()
    expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined()
  })

  it('claims wake-word ownership when the gateway becomes ready', () => {
    const ctx = buildCtx([])

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' })
  })

  it('ends voice mode on a stop-phrase transcript without submitting a turn', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { stop_phrase: true, text: 'stop' }, type: 'voice.transcript' } as any)

    expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(false)
    expect(ctx.voice.setRecording).toHaveBeenCalledWith(false)
    expect(ctx.voice.setProcessing).toHaveBeenCalledWith(false)
    // The stop phrase is user intent to END the chat — never a turn.
    expect(ctx.submission.submitRef.current).not.toHaveBeenCalled()
  })

  it('ends voice mode on a typed stop phrase consumed server-side', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { stop_phrase: true, typed: true }, type: 'voice.transcript' } as any)

    expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(false)
    expect(ctx.submission.submitRef.current).not.toHaveBeenCalled()
  })

  it('still submits ordinary voice transcripts as turns', async () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { text: 'stop the docker container' }, type: 'voice.transcript' } as any)

    await vi.waitFor(() => expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('stop the docker container'))
    expect(ctx.voice.setVoiceEnabled).not.toHaveBeenCalled()
  })

  it('leaves voice transcripts editable when voice.submit_mode is draft', async () => {
    const ctx = buildCtx([])
    let composerInput = 'existing draft'

    ctx.gateway.rpc = vi.fn(async (method: string) =>
      method === 'config.get' ? { config: { voice: { submit_mode: 'draft' } } } : null
    )
    ctx.composer.setInput = vi.fn((next: string | ((current: string) => string)) => {
      composerInput = typeof next === 'function' ? next(composerInput) : next
    })
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { text: '  edit this first  ' }, type: 'voice.transcript' } as any)

    await vi.waitFor(() => expect(composerInput).toBe('existing draft edit this first'))
    expect(ctx.submission.submitRef.current).not.toHaveBeenCalled()
  })

  it('falls back to direct submit for an invalid voice.submit_mode', async () => {
    const ctx = buildCtx([])

    ctx.gateway.rpc = vi.fn(async (method: string) =>
      method === 'config.get' ? { config: { voice: { submit_mode: 'refine' } } } : null
    )
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { text: 'send safely' }, type: 'voice.transcript' } as any)

    await vi.waitFor(() => expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('send safely'))
    expect(ctx.composer.setInput).toHaveBeenCalledWith('')
  })

  it('opens a fresh session before starting voice after wake detection', async () => {
    const ctx = buildCtx([])
    ctx.session.newSession = vi.fn(async () => patchUiState({ sid: 'wake-session' }))
    patchUiState({ sid: 'old-session' })

    createGatewayEventHandler(ctx)({
      payload: { phrase: 'hey hermes', start_new_session: true },
      type: 'wake.detected'
    } as any)

    await vi.waitFor(() =>
      expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
        action: 'start',
        session_id: 'wake-session'
      })
    )
    expect(ctx.session.newSession).toHaveBeenCalledOnce()
    expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(true)
  })

  it('keeps the current session when wake detection disables session creation', async () => {
    const ctx = buildCtx([])
    patchUiState({ sid: 'current-session' })

    createGatewayEventHandler(ctx)({
      payload: { phrase: 'hey hermes', start_new_session: false },
      type: 'wake.detected'
    } as any)

    await vi.waitFor(() =>
      expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
        action: 'start',
        session_id: 'current-session'
      })
    )
    expect(ctx.session.newSession).not.toHaveBeenCalled()
  })

  it('rearms wake detection when no session is available', async () => {
    const ctx = buildCtx([])
    patchUiState({ sid: '' })

    createGatewayEventHandler(ctx)({
      payload: { start_new_session: false },
      type: 'wake.detected'
    } as any)

    await vi.waitFor(() => expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.resume', {}))
    expect(ctx.gateway.rpc).not.toHaveBeenCalledWith('voice.record', expect.anything())
  })

  it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.gateway.rpc = vi.fn(async (method: string) => {
      if (method === 'config.get') {
        return { config: { display: { tui_auto_resume_recent: false } } }
      }

      return null
    })

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
    expect(resumeById).not.toHaveBeenCalled()
  })

  it('on gateway.ready after a crash, resumes the recovered session once and skips forge', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const resumed = Promise.withResolvers<void>()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    // Mimic resumeById's synchronous status write so the test proves the
    // "recovering session…" label is applied *after* (and survives) it.
    ctx.session.resumeById = resumeById.mockImplementation(() => {
      patchUiState({ status: 'resuming…' })

      return resumed.promise.then(() => patchUiState({ sid: 'sess-recovered', status: 'ready' }))
    })
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.session.recoverSidRef = ref<null | string>('sess-crashed')

    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-crashed'))
    expect(newSession).not.toHaveBeenCalled()
    expect(ctx.session.recoverSidRef.current).toBe('sess-crashed')
    expect(getUiState().status).not.toBe('resuming…')

    resumed.resolve()
    await vi.waitFor(() => expect(ctx.session.recoverSidRef.current).toBeNull())
    expect(getUiState().sid).toBe('sess-recovered')
  })

  it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.gateway.rpc = vi.fn(async (method: string) => {
      if (method === 'config.get') {
        return { config: { display: { tui_auto_resume_recent: true } } }
      }

      if (method === 'session.most_recent') {
        return { session_id: 'sess-most-recent' }
      }

      return null
    })

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent'))
    expect(newSession).not.toHaveBeenCalled()
  })

  it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.gateway.rpc = vi.fn(async (method: string) => {
      if (method === 'config.get') {
        return { config: { display: { tui_auto_resume_recent: true } } }
      }

      if (method === 'session.most_recent') {
        return { session_id: null }
      }

      return null
    })

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
    expect(resumeById).not.toHaveBeenCalled()
  })

  it('on gateway.ready when config.get rejects, falls back to new session', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.gateway.rpc = vi.fn(async (method: string) => {
      if (method === 'config.get') {
        throw new Error('gateway timeout')
      }

      return null
    })

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
    expect(resumeById).not.toHaveBeenCalled()
  })

  it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = ''
    ctx.gateway.rpc = vi.fn(async (method: string) => {
      if (method === 'config.get') {
        return { config: { display: { tui_auto_resume_recent: true } } }
      }

      if (method === 'session.most_recent') {
        throw new Error('db locked')
      }

      return null
    })

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
    expect(resumeById).not.toHaveBeenCalled()
  })

  it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => {
    const appended: Msg[] = []
    const newSession = vi.fn()
    const resumeById = vi.fn()
    const ctx = buildCtx(appended)

    ctx.session.newSession = newSession
    ctx.session.resumeById = resumeById
    ctx.session.STARTUP_RESUME_ID = 'env-explicit'
    ctx.gateway.rpc = vi.fn(async () => ({
      config: { display: { tui_auto_resume_recent: true } }
    }))

    createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)

    await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit'))
    expect(newSession).not.toHaveBeenCalled()
  })

  it('keeps gateway noise informational and approval out of Activity', async () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    ctx.gateway.rpc = vi.fn(async () => {
      throw new Error('cold start')
    })

    const onEvent = createGatewayEventHandler(ctx)

    onEvent({ payload: { line: 'INFO hermes.mcp: 3 servers discovered' }, type: 'gateway.stderr' } as any)
    onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
    serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' })
    onEvent({ payload: {}, type: 'gateway.ready' } as any)

    await Promise.resolve()
    await Promise.resolve()

    expect(getOverlayState().approval).toMatchObject({ description: 'dangerous command' })
    // Plain stderr chatter never reaches Activity (it stays in /logs).
    const activity = getTurnState().activity
    expect(activity.map(a => a.tone)).toEqual(['info', 'info', 'info'])
    expect(activity.some(a => a.text.includes('servers discovered'))).toBe(false)
  })

  it('defaults approval overlays to allowPermanent when the backend omits the field', () => {
    serverRequest('approval', { command: 'rm -rf /tmp/x', description: 'dangerous command' })

    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true, requestId: 'srq-approval' })
  })

  it('preserves allow_permanent=false on approval overlays', () => {
    serverRequest('approval', {
      allow_permanent: false,
      command: 'curl suspicious | bash',
      description: 'content-security warning'
    })

    expect(getOverlayState().approval).toMatchObject({
      allowPermanent: false,
      command: 'curl suspicious | bash',
      description: 'content-security warning'
    })
  })

  it('preserves Smart DENY and explicit approval choices on the overlay', () => {
    serverRequest('approval', {
      allow_permanent: true,
      choices: ['once', 'deny'],
      command: 'rm -rf /tmp/x',
      description: 'smart deny override',
      smart_denied: true
    })

    expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true })
  })

  it('declines the requests a terminal cannot answer so the channel fails them fast', () => {
    for (const method of ['preview.act', 'window.read', 'tour', 'mcp.setup', 'terminal.read']) {
      expect(serverRequest(method, {}).handled).toBe(false)
    }
  })

  it('still surfaces terminal turn failures as errors', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { message: 'boom' }, type: 'error' } as any)

    expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
  })

  it('accepts timeout/error subagent terminal statuses and ignores stale live events', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
      type: 'subagent.start'
    } as any)
    onEvent({
      payload: { goal: 'timeout child', status: 'timeout', subagent_id: 'sa-timeout', task_index: 0 },
      type: 'subagent.complete'
    } as any)

    expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')

    // Late start/spawn updates must not clobber terminal timeout/error states.
    onEvent({
      payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
      type: 'subagent.start'
    } as any)
    onEvent({
      payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 },
      type: 'subagent.spawn_requested'
    } as any)

    expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout')

    onEvent({
      payload: { goal: 'error child', subagent_id: 'sa-error', task_index: 1 },
      type: 'subagent.start'
    } as any)
    onEvent({
      payload: { goal: 'error child', status: 'error', subagent_id: 'sa-error', task_index: 1 },
      type: 'subagent.complete'
    } as any)

    expect(getTurnState().subagents.find(s => s.id === 'sa-error')?.status).toBe('error')
  })

  it('normalizes unknown subagent.complete statuses to completed', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { goal: 'weird child', subagent_id: 'sa-weird', task_index: 2 },
      type: 'subagent.start'
    } as any)
    onEvent({
      payload: { goal: 'weird child', status: 'mystery_status', subagent_id: 'sa-weird', task_index: 2 },
      type: 'subagent.complete'
    } as any)

    expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed')
  })

  it('nudges toward /agents on the first spawn_requested of a turn', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
      type: 'subagent.spawn_requested'
    } as any)

    const hints = getTurnState().activity.filter(a => a.text.includes('/agents'))
    expect(hints).toHaveLength(1)
    expect(hints[0]).toMatchObject({ tone: 'info' })
  })

  it('nudges at most once per turn and resets on the next message.start', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // Multiple spawns in one turn → a single hint.
    onEvent({
      payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
      type: 'subagent.start'
    } as any)
    onEvent({
      payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
      type: 'subagent.start'
    } as any)
    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)

    // New turn clears activity AND the once-per-turn guard → nudges again.
    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({
      payload: { goal: 'child c', subagent_id: 'sa-c', task_index: 0 },
      type: 'subagent.start'
    } as any)
    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
  })

  it('does not nudge when the /agents overlay is already open', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // User already has the dashboard open → nothing to advertise.
    patchOverlayState({ agents: true })

    onEvent({
      payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
      type: 'subagent.start'
    } as any)

    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
  })

  it('nudges if the /agents overlay is closed mid-turn while delegation continues', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // Overlay open on the first delegation event → suppressed, but the
    // turn's nudge credit must NOT be burned (the user is watching).
    patchOverlayState({ agents: true })
    onEvent({
      payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
      type: 'subagent.start'
    } as any)
    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)

    // User closes the dashboard mid-turn → the next delegation event nudges.
    patchOverlayState({ agents: false })
    onEvent({
      payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
      type: 'subagent.start'
    } as any)
    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
  })

  it('does not nudge when display.tui_agents_nudge is false', async () => {
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    // config.get → full returns the disable flag.
    ctx.gateway.rpc = vi.fn(async (method: string) =>
      method === 'config.get' ? { config: { display: { tui_agents_nudge: false } } } : null
    )
    const onEvent = createGatewayEventHandler(ctx)

    // Config fetch starts once the gateway is ready; let it resolve before any
    // spawn (mirrors real usage — config lands well before first delegation).
    onEvent({ payload: {}, type: 'gateway.ready' } as any)
    await Promise.resolve()
    await Promise.resolve()

    onEvent({
      payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
      type: 'subagent.start'
    } as any)

    expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
  })

  it('keeps a canonical turn live when interrupt is pending or rejected, without retrying', async () => {
    vi.useFakeTimers()

    try {
      for (const error of [new Error('stale_generation'), new Error('transport disconnected')]) {
        turnController.fullReset()
        const appended: Msg[] = []
        const ctx = buildCtx(appended)
        let reject!: (error: Error) => void
        ctx.gateway.gw.isCanonical = true
        ctx.gateway.gw.request = vi.fn(
          () =>
            new Promise((_, fail) => {
              reject = fail
            })
        )
        const onEvent = createGatewayEventHandler(ctx)
        const info = { model: 'test', tools: {}, skills: {}, execution_epoch: 'owner', execution_generation: 2 }
        patchUiState({ sid: 'sess-1', info })

        const emit = (type: string, payload = {}) =>
          onEvent({ type, session_id: 'sess-1', payload: { ...info, ...payload } } as any)

        emit('message.start')
        emit('reasoning.delta', { text: 'working' })
        emit('tool.start', { name: 'search', tool_id: 't-1' })
        emit('message.delta', { text: 'partial' })
        emit('approval.request', { request_id: 'approval', command: 'test' })
        const live = getTurnState()
        const overlay = getOverlayState().approval

        const pending = turnController.interruptTurn(
          {
            appendMessage: ctx.transcript.appendMessage,
            gw: ctx.gateway.gw,
            sid: 'sess-1',
            sys: ctx.system.sys
          },
          { keepBusy: true }
        )

        expect(turnController.interrupted).toBe(false)
        expect(getUiState().busy).toBe(true)
        expect(getTurnState()).toEqual(live)
        expect(getOverlayState().approval).toBe(overlay)
        expect(appended).toEqual([])
        reject(error)
        await pending

        expect(ctx.gateway.gw.request).toHaveBeenCalledExactlyOnceWith('session.interrupt', {
          session_id: 'sess-1',
          execution_generation: info.execution_generation
        })
        expect(ctx.system.sys).toHaveBeenCalledExactlyOnceWith(`interrupt failed: ${error.message}`)
        expect(turnController.interrupted).toBe(false)
        expect(getUiState()).toMatchObject({ sid: 'sess-1', busy: true, info })
        expect(getTurnState()).toEqual(live)
        expect(getOverlayState().approval).toBe(overlay)
        emit('message.delta', { text: ' continues' })
        expect(turnController.bufRef).toBe('partial continues')
        emit('message.complete', { text: 'legitimate completion' })
        expect(getUiState().busy).toBe(false)
        expect(appended.some(msg => msg.text === 'legitimate completion')).toBe(true)
        expect(appended.some(msg => msg.text.includes('[interrupted]'))).toBe(false)
      }
    } finally {
      turnController.fullReset()
      vi.clearAllTimers()
      vi.useRealTimers()
    }
  })

  it('applies canonical interrupt acknowledgement only to the still-active turn, letting completion win', async () => {
    vi.useFakeTimers()

    try {
      for (const race of [
        'none',
        'completion',
        'idle snapshot',
        'settled acknowledgement',
        'session',
        'generation',
        'epoch'
      ]) {
        for (const keepBusy of [false, true]) {
          turnController.fullReset()
          const appended: Msg[] = []
          const ctx = buildCtx(appended)
          let resolve!: (result: unknown) => void
          ctx.gateway.gw.isCanonical = true
          ctx.gateway.gw.request = vi.fn(
            () =>
              new Promise(done => {
                resolve = done
              })
          )
          const onEvent = createGatewayEventHandler(ctx)
          const info = { model: 'test', tools: {}, skills: {}, execution_epoch: 'owner', execution_generation: 2 }
          patchUiState({ sid: 'sess-1', info })

          const emit = (type: string, payload = {}) =>
            onEvent({ type, session_id: 'sess-1', payload: { ...info, ...payload } } as any)

          emit('message.start')
          emit('message.delta', { text: 'partial' })

          const pending = turnController.interruptTurn(
            {
              appendMessage: ctx.transcript.appendMessage,
              gw: ctx.gateway.gw,
              sid: 'sess-1',
              sys: ctx.system.sys
            },
            { keepBusy }
          )

          expect(turnController.interrupted).toBe(false)
          expect(getUiState().busy).toBe(true)
          expect(appended).toEqual([])

          if (race === 'completion') {
            emit('message.complete', { text: 'completed before acknowledgement' })
            expect(appended).toContainEqual({ role: 'assistant', text: 'completed before acknowledgement' })
          } else if (race === 'idle snapshot') {
            emit('session.info', { running: false })
          } else if (race !== 'none' && race !== 'settled acknowledgement') {
            patchUiState({
              sid: race === 'session' ? 'sess-2' : 'sess-1',
              info: {
                ...info,
                execution_epoch: race === 'epoch' ? 'replacement' : info.execution_epoch,
                execution_generation: race === 'generation' ? 3 : info.execution_generation
              }
            })
            turnController.startMessage()
            turnController.hydrateStreamingText('new active turn')
          }

          const beforeAck = { ui: getUiState(), turn: getTurnState(), messages: [...appended] }
          // Canonical success is a SessionHandle, not the legacy {ok: true}.
          resolve({
            ref: { profile_id: 'default', session_id: 'sess-1' },
            instance_id: 'instance',
            authority_epoch: 1,
            revision: 3,
            execution_generation: 2,
            execution_state: race === 'settled acknowledgement' ? 'idle' : 'running'
          })
          await pending

          expect(ctx.gateway.gw.request).toHaveBeenCalledTimes(1)
          expect(ctx.system.sys).not.toHaveBeenCalled()

          if (race === 'none') {
            expect(turnController.interrupted).toBe(true)
            expect(getUiState()).toMatchObject({ busy: keepBusy, status: keepBusy ? 'interrupting…' : 'interrupted' })
            expect(appended).toEqual([{ role: 'assistant', text: 'partial\n\n*[interrupted]*' }])
            emit('message.complete', { text: 'cancelled turn response' })
            expect(getUiState().busy).toBe(false)
            expect(appended).toHaveLength(1)
          } else {
            expect(turnController.interrupted).toBe(false)
            expect({ ui: getUiState(), turn: getTurnState(), messages: appended }).toEqual(beforeAck)
          }

          if (race === 'settled acknowledgement') {
            emit('message.complete', { text: 'completed before the interrupt reached the server' })
            // The streamed tail the user watched is kept as its own segment when the final
            // text does not carry it (#61520); the final lands once, after it.
            expect(appended).toEqual([
              { role: 'assistant', text: 'partial' },
              { role: 'assistant', text: 'completed before the interrupt reached the server' }
            ])
            expect(getUiState().busy).toBe(false)
          }
        }
      }
    } finally {
      turnController.fullReset()
      vi.clearAllTimers()
      vi.useRealTimers()
    }
  })

  it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
    // Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
    // events from the still-winding-down agent loop kept populating the UI for
    // ~1s, making it look like the interrupt had been ignored.
    //
    // Fake timers because `interruptTurn` schedules a real setTimeout for
    // its cooldown — without flushing it inside this test, the timeout
    // can fire later and mutate uiStore/turnState during unrelated tests
    // (cross-file flake).
    vi.useFakeTimers()

    try {
      const appended: Msg[] = []
      const ctx = buildCtx(appended)
      ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
      const onEvent = createGatewayEventHandler(ctx)

      patchUiState({ sid: 'sess-1' })
      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({
        payload: {
          context: 'pre',
          name: 'search',
          todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }],
          tool_id: 't-1'
        },
        type: 'tool.complete'
      } as any)

      // Pre-interrupt todos should land in turn state.
      expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }])

      turnController.interruptTurn({
        appendMessage: (msg: Msg) => appended.push(msg),
        gw: ctx.gateway.gw,
        sid: 'sess-1',
        sys: ctx.system.sys
      })

      onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
      // Post-interrupt tool.complete with a todos payload — must NOT mutate todos.
      onEvent({
        payload: {
          context: 'post',
          name: 'browser',
          todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }],
          tool_id: 't-2'
        },
        type: 'tool.complete'
      } as any)
      // Late tool.generating must NOT push a 'drafting …' line into the trail.
      const trailBefore = getTurnState().turnTrail.length
      onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any)
      expect(getTurnState().turnTrail.length).toBe(trailBefore)
      onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
      onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)

      expect(getTurnState().tools).toEqual([])
      expect(turnController.reasoningText).toBe('')
      expect(turnController.bufRef).toBe('')
      expect(getTurnState().streamPendingTools).toEqual([])
      expect(getTurnState().streamSegments).toEqual([])
      // Stale post-interrupt todos must not have leaked through.
      // (This test does not assert that pre-interrupt todos are cleared —
      // current interrupt path leaves them visible until the next message.)
      expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined()

      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)

      expect(turnController.reasoningText).toBe('fresh')
    } finally {
      // Drain pending fake timers BEFORE restoring real timers so a mid-
      // test assertion failure can't leak the interrupt-cooldown setTimeout
      // across test files (the original Copilot concern).
      vi.runAllTimers()
      vi.useRealTimers()
    }
  })

  // Ctrl+C seals the reply at the keypress, but the agent streams until it
  // notices the interrupt and persists everything it streamed (state.db and the
  // next request's history). The screen must show that same partial.
  const interruptedTranscript = (deltas: string[], late: string[], persisted: string) => {
    vi.useFakeTimers()

    try {
      const history: Msg[] = []
      const ctx = buildCtx(history)
      ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
      ctx.transcript.setHistoryItems = (next: ((prev: Msg[]) => Msg[]) | Msg[]) =>
        history.splice(0, history.length, ...(typeof next === 'function' ? next([...history]) : next))
      const onEvent = createGatewayEventHandler(ctx)

      patchUiState({ sid: 'sess-1' })
      onEvent({ payload: {}, type: 'message.start' } as any)
      deltas.forEach(text => onEvent({ payload: { text }, type: 'message.delta' } as any))
      turnController.interruptTurn({
        appendMessage: (msg: Msg) => history.push(msg),
        gw: ctx.gateway.gw,
        sid: 'sess-1',
        sys: ctx.system.sys
      })
      late.forEach(text => onEvent({ payload: { text }, type: 'message.delta' } as any))
      onEvent({ payload: { status: 'interrupted', text: persisted }, type: 'message.complete' } as any)

      return history.filter(m => m.role === 'assistant').map(m => m.text)
    } finally {
      vi.runAllTimers()
      vi.useRealTimers()
    }
  }

  it('an interrupted reply shows the partial the agent persisted, including deltas streamed after Ctrl+C', () => {
    expect(interruptedTranscript(['alpha beta', ' ga'], ['mma', ' delta'], 'alpha beta gamma delta')).toEqual([
      'alpha beta gamma delta\n\n*[interrupted]*'
    ])
  })

  it('an interrupted reply whose every delta landed after Ctrl+C still shows the persisted partial', () => {
    expect(interruptedTranscript([], ['alpha', ' beta'], 'alpha beta')).toEqual(['alpha beta\n\n*[interrupted]*'])
  })

  it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turn’s final_response', () => {
    // Force-send: interrupt holds busy so the drain waits for the real settle
    // instead of racing it (the race duplicated the bubble, leaked a "queued: …"
    // note, and surfaced the cancelled turn's "Operation interrupted…" reply).
    const appended: Msg[] = []
    const ctx = buildCtx(appended)
    ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
    const onEvent = createGatewayEventHandler(ctx)

    patchUiState({ sid: 'sess-1' })
    onEvent({ payload: {}, type: 'message.start' } as any)
    onEvent({ payload: { text: 'thinking…' }, type: 'reasoning.delta' } as any)
    expect(getUiState().busy).toBe(true)

    turnController.interruptTurn(
      { appendMessage: (msg: Msg) => appended.push(msg), gw: ctx.gateway.gw, sid: 'sess-1', sys: ctx.system.sys },
      { keepBusy: true }
    )

    // Held busy: the drain effect keys off busy→false, so it must not fire yet.
    expect(getUiState().busy).toBe(true)

    // The cancelled turn settles with a backend interrupted final_response.
    const before = appended.length
    onEvent({
      payload: { text: 'Operation interrupted: waiting for model response (4.1s elapsed).' },
      type: 'message.complete'
    } as any)

    // Settle flips busy false (the single drain edge) and the backend
    // "Operation interrupted…" line is suppressed (not appended).
    expect(getUiState().busy).toBe(false)
    expect(
      appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))
    ).toBe(false)
  })

  it('only persists an abandoned clarify once even if tool.complete fires twice', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    patchOverlayState({
      clarify: { questions: [{ choices: ['A'], qid: 'q0', question: 'Pick?' }], requestId: 'req-3' }
    })

    onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
    // A duplicate clarify tool.complete must not re-persist the same prompt.
    onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)

    const records = appended.filter(msg => msg.role === 'system' && msg.text.startsWith('ask ('))
    expect(records).toHaveLength(1)
  })

  it('does not flush the clarify overlay when a non-clarify tool completes', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // A clarify is live, but it's a *different* tool that just completed — the
    // clarify itself is still pending, so we must not persist or clear it.
    patchOverlayState({
      clarify: { questions: [{ choices: ['A', 'B'], qid: 'q0', question: 'Pick?' }], requestId: 'req-4' }
    })

    onEvent({ payload: { name: 'search', tool_id: 'tool-1' }, type: 'tool.complete' } as any)

    expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
    expect(getOverlayState().clarify).not.toBeNull()
  })

  it('does not persist when an answered clarify already cleared the overlay before tool.complete', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    // Answered path (answerClarifyQuestion) clears the overlay before the agent's
    // tool.complete arrives, so there's nothing live to persist.
    onEvent({ payload: { duration_s: 4.2, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)

    expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
  })

  it('clears only the card whose request the gateway withdrew (request.cancel by id)', () => {
    const onEvent = createGatewayEventHandler(buildCtx([]))

    serverRequest('secret', { env_var: 'NEW_KEY', prompt: 'Enter new key' }, 'secret-new')
    serverRequest('sudo', {}, 'sudo-1')

    onEvent({ payload: { id: 'secret-old', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)
    expect(getOverlayState().secret?.requestId).toBe('secret-new')

    onEvent({ payload: { id: 'secret-new', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)
    expect(getOverlayState().secret).toBeNull()
    expect(getOverlayState().sudo?.requestId).toBe('sudo-1')

    onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any)
    expect(getOverlayState().sudo).toBeNull()
  })

  it('tells the user a timed-out password prompt was withdrawn and the step skipped', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)

    serverRequest('sudo', {}, 'sudo-1')
    onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'timeout' }, type: 'request.cancel' } as any)

    expect(getOverlayState().sudo).toBeNull()
    const lines = (ctx.system.sys as any).mock.calls.map((c: unknown[]) => String(c[0]))
    expect(lines.length).toBeGreaterThan(0)

    // An interrupted prompt is the user's own doing — no notice.
    serverRequest('sudo', {}, 'sudo-2')
    onEvent({ payload: { id: 'sudo-2', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any)
    expect((ctx.system.sys as any).mock.calls.length).toBe(lines.length)
  })

  it('renders a failed turn from error_surface instead of the raw provider JSON', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    const raw =
      'Error code: 401 - {"error": {"message": "Incorrect API key provided", "type": "invalid_request_error"}}'

    onEvent({
      payload: {
        error: raw,
        error_surface: { code: 'auth', layer: 'auth', provider: 'openai', retryable: false },
        recoverable: true,
        status: 'error',
        text: `Error: ${raw}`
      },
      type: 'message.complete'
    } as any)

    const assistant = appended.filter(m => m.role === 'assistant')
    expect(assistant).toHaveLength(1)
    const [title, details] = assistant[0]!.text.split('\n')
    expect(title).not.toMatch(/^Error(?: code)?:/)
    expect(title).toMatch(/API key/)
    expect(details).toMatch(/^Details: .*Incorrect API key provided/)
    expect(assistant[0]!.text).toContain('/model')
    expect(assistant[0]!.text).toContain('/retry')
  })

  it('keeps interim assistant segments on a failed turn and replaces only the bare error slot', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({ payload: { text: 'Let me look that up first.' }, type: 'message.interim' } as any)
    onEvent({
      payload: {
        error: 'boom',
        error_surface: { code: 'server_error', layer: 'provider', retryable: true },
        recoverable: true,
        status: 'error',
        text: 'Error: boom'
      },
      type: 'message.complete'
    } as any)

    const assistant = appended.filter(m => m.role === 'assistant')
    expect(assistant.some(m => m.text === 'Let me look that up first.')).toBe(true)
    expect(assistant.some(m => /^Error: boom/.test(m.text))).toBe(false)
    expect(assistant.at(-1)!.text).toMatch(/internal error/)
    expect(assistant.at(-1)!.text).toContain('/retry')
  })

  it('keeps streamed partial text on a failed turn (only the empty-reply case is rewritten)', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    onEvent({
      payload: { error: 'stream dropped', partial: true, status: 'error', text: 'Here is the first half' },
      type: 'message.complete'
    } as any)

    expect(appended.some(m => m.role === 'assistant' && m.text === 'Here is the first half')).toBe(true)
  })

  it('shows the reconnect countdown from gateway.reconnecting in the status bar', () => {
    const onEvent = createGatewayEventHandler(buildCtx([]))

    onEvent({ payload: { attempt: 2, delay_ms: 4000 }, type: 'gateway.reconnecting' } as any)

    expect(getUiState().status).toMatch(/\b4s\b/)
    expect(getUiState().status).toMatch(/\b2\b/)
  })

  it('glosses a version-skew error event as an /update pointer', () => {
    const ctx = buildCtx([])
    const onEvent = createGatewayEventHandler(ctx)

    onEvent({
      payload: { message: 'invalid params for prompt.submit: turn_author: Extra inputs are not permitted' },
      type: 'error'
    } as any)

    const line = String((ctx.system.sys as any).mock.calls.at(-1)?.[0])
    expect(line).toContain('/update')
    expect(line).not.toContain('turn_author')
  })

  // ── Batch (multi-question) clarify ─────────────────────────────────

  it('parses a batch clarify request into a questions overlay', () => {
    serverRequest(
      'clarify',
      {
        questions: [
          { choices: ['a', 'b'], qid: 'q0', question: 'One?' },
          { choices: null, qid: 'q1', question: 'Two?' }
        ]
      },
      'req-batch'
    )

    const clarify = getOverlayState().clarify
    expect(clarify?.requestId).toBe('req-batch')
    expect(clarify?.questions).toHaveLength(2)
    expect(clarify?.questions?.[0]?.qid).toBe('q0')
    expect(clarify?.questions?.[1]?.choices).toBeNull()
    expect(clarify?.answers).toEqual({})
  })

  it('maps a canonical one-card clarify.request onto the one-question card with its shared control', () => {
    const onEvent = createGatewayEventHandler(buildCtx([]))

    onEvent({
      payload: {
        choices: ['a', 'b'],
        execution_generation: 3,
        multi_select: true,
        prompt_id: 'p-9',
        question: ' Pick? '
      },
      session_id: 'sid-c',
      type: 'clarify.request'
    } as any)

    expect(getOverlayState().clarify).toEqual({
      answers: {},
      questions: [{ choices: ['a', 'b'], multiSelect: true, qid: 'p-9', question: 'Pick?' }],
      requestId: 'p-9',
      sharedControl: { execution_generation: 3, prompt_id: 'p-9', session_id: 'sid-c' }
    })
  })

  it('seeds locked answers from a reconnect-replayed batch clarify request', () => {
    serverRequest(
      'clarify',
      {
        answers: { q0: 'a' },
        questions: [
          { choices: ['a', 'b'], qid: 'q0', question: 'One?' },
          { choices: null, qid: 'q1', question: 'Two?' }
        ]
      },
      'req-replay'
    )

    expect(getOverlayState().clarify?.answers).toEqual({ q0: 'a' })
  })

  it('persists an abandoned batch clarify with its locked partials on tool.complete', () => {
    const appended: Msg[] = []
    const onEvent = createGatewayEventHandler(buildCtx(appended))

    patchOverlayState({
      clarify: {
        answers: { q0: 'alpha' },
        questions: [
          { choices: ['alpha', 'beta'], qid: 'q0', question: 'One?' },
          { choices: null, qid: 'q1', question: 'Two?' }
        ],
        requestId: 'req-batch-timeout'
      }
    })

    onEvent({ payload: { name: 'clarify', tool_id: 'clar-b' }, type: 'tool.complete' } as any)

    const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask (2 questions)'))
    expect(record).toBeDefined()
    expect(record?.text).toContain('alpha')
    expect(record?.text).toContain('Two?')
    expect(getOverlayState().clarify).toBeNull()
  })

  describe('session.usage', () => {
    it('merges a live usage tick into uiState (payload.usage shape, see tui_gateway _start_usage_ticker)', () => {
      patchUiState({ sid: 'sess-1' })
      const onEvent = createGatewayEventHandler(buildCtx([]))

      onEvent({
        payload: { usage: { calls: 3, context_percent: 42, input: 1200, output: 80, total: 1280 } },
        session_id: 'sess-1',
        type: 'session.usage'
      } as any)

      expect(getUiState().usage).toMatchObject({ context_percent: 42, input: 1200, total: 1280 })
    })

    it('keeps existing usage fields when the tick only carries a subset', () => {
      patchUiState({ sid: 'sess-1', usage: { calls: 2, input: 500, output: 40, total: 540 } })
      const onEvent = createGatewayEventHandler(buildCtx([]))

      onEvent({
        payload: { usage: { context_percent: 55 } },
        session_id: 'sess-1',
        type: 'session.usage'
      } as any)

      expect(getUiState().usage).toMatchObject({ context_percent: 55, input: 500, total: 540 })
    })

    it('drops a tick for a non-focused session', () => {
      patchUiState({ sid: 'focused', usage: ZERO })
      const onEvent = createGatewayEventHandler(buildCtx([]))

      onEvent({
        payload: { usage: { input: 9999, total: 9999 } },
        session_id: 'background',
        type: 'session.usage'
      } as any)

      expect(getUiState().usage).toEqual(ZERO)
    })
  })

  describe('message.interim', () => {
    it('finalizes an interim segment without settling the turn', () => {
      const appended: Msg[] = []
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { text: 'streaming text' }, type: 'message.delta' } as any)
      onEvent({ payload: { already_streamed: true, text: 'streaming text' }, type: 'message.interim' } as any)

      // Turn is still active — busy stays true, no completion messages appended
      expect(getUiState().busy).toBe(true)
      expect(appended).toHaveLength(0)
    })

    it('keeps identical interim and terminal replies as separate messages without response_previewed', () => {
      const appended: Msg[] = []
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)
      onEvent({ payload: { text: 'same reply' }, type: 'message.complete' } as any)

      const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)
      expect(assistantMsgs).toHaveLength(2)
    })

    it('settles identical terminal reply onto interim when response_previewed', () => {
      const appended: Msg[] = []
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: {}, type: 'message.start' } as any)
      onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)
      onEvent({ payload: { response_previewed: true, text: 'same reply' }, type: 'message.complete' } as any)

      // With response_previewed, the terminal reply is the same model
      // response that was published provisionally — settle onto the
      // interim instead of duplicating. (#65919 review)
      const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)
      expect(assistantMsgs).toHaveLength(1)
      expect(assistantMsgs[0]?.text).toBe('same reply')
    })

    it('deduplicates flushed chunks within the terminal message after an interim boundary', () => {
      const appended: Msg[] = []
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: {}, type: 'message.start' } as any)
      // Interim seals the first segment
      onEvent({ payload: { already_streamed: true, text: 'interim answer' }, type: 'message.interim' } as any)
      // Post-interim deltas that match the final text — these get deduped
      onEvent({ payload: { text: 'final answer' }, type: 'message.delta' } as any)
      onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)

      const texts = appended.filter(m => m.role === 'assistant' && m.text).map(m => m.text)
      // interim + final, no duplication of the final
      expect(texts).toContain('interim answer')
      expect(texts.filter(t => t === 'final answer')).toHaveLength(1)
    })

    it('ignores malformed message.interim payload', () => {
      const appended: Msg[] = []
      const onEvent = createGatewayEventHandler(buildCtx(appended))

      onEvent({ payload: {}, type: 'message.start' } as any)
      // No payload at all
      onEvent({ type: 'message.interim' } as any)
      // Empty text
      onEvent({ payload: { text: '' }, type: 'message.interim' } as any)
      // Undefined text
      onEvent({ payload: { text: undefined }, type: 'message.interim' } as any)

      // Turn continues without finalizing or throwing
      expect(getUiState().busy).toBe(true)
      expect(appended).toHaveLength(0)
    })

    describe('vault.save_login prompt (#109101)', () => {
      it('opens the two-step save-login card for the server request', () => {
        const { handled } = serverRequest(
          'vault.save_login',
          {
            origin: 'https://www.linkedin.com',
            session_id: 'sess',
            site: 'www.linkedin.com'
          },
          'save-9'
        )

        expect(handled).toBe(true)
        expect(getOverlayState().vaultSaveLogin).toEqual({
          origin: 'https://www.linkedin.com',
          requestId: 'save-9',
          site: 'www.linkedin.com'
        })
        expect(getUiState().status).toBe('save login for www.linkedin.com')
      })

      it('tears the card down on request.cancel, but only for the matching request', () => {
        const onEvent = createGatewayEventHandler(buildCtx([]))

        serverRequest(
          'vault.save_login',
          {
            origin: 'https://a.example',
            session_id: 'sess',
            site: 'a.example'
          },
          'save-1'
        )
        expect(getOverlayState().vaultSaveLogin).not.toBeNull()

        onEvent({ payload: { id: 'save-2' }, type: 'request.cancel' } as any)
        expect(getOverlayState().vaultSaveLogin).not.toBeNull()

        onEvent({ payload: { id: 'save-1' }, type: 'request.cancel' } as any)
        expect(getOverlayState().vaultSaveLogin).toBeNull()
      })

      it('opens the verification-code card for vault.code and tears it down on request.cancel', () => {
        const onEvent = createGatewayEventHandler(buildCtx([]))

        const { handled } = serverRequest(
          'vault.code',
          { hint: 'sent to •••42', session_id: 'sess', site: 'github.com' },
          'code-1'
        )

        expect(handled).toBe(true)
        expect(getOverlayState().vaultCode).toEqual({ hint: 'sent to •••42', requestId: 'code-1', site: 'github.com' })
        expect(getUiState().status).toBe('verification code for github.com')

        onEvent({ payload: { id: 'code-1' }, type: 'request.cancel' } as any)
        expect(getOverlayState().vaultCode).toBeNull()
      })
    })
  })
})
