import type { ACPContent, ACPMessage } from '#cli/core/acp'
import type {
  ApprovalRequest,
  AssistantContentPart,
  GoatChainContext,
  LlmMetaChangedFile,
  Message,
  RunConfig,
  TokenUsage,
  ToolApprovalsMode,
  ToolCall,
  ToolPermission,
  ToolResult,
} from '#cli/core/model'
import type { FileRevert } from './file-reverts'
import type { ThinkingStateManager } from './stream-runner-thinking'
import type {
  createToolCallTranscriptLookup,
  StreamEventMetadata,
} from './stream-runner-utils'
import type { PausedState } from './types'
import path from 'node:path'
import { getOriginalEventType } from '#cli/core/acp-events'
import { debugLog } from '#cli/core/debug-log'
import { formatHookEvaluationInfoText } from '#cli/core/hook-evaluation-info'
import { createLocalThinkingPartId } from '#cli/core/transcript-part-id'
import { isReadLikeToolName } from '#cli/core/tool-metadata'
import {
  popPerfSpan,
  pushPerfSpan,
  withPerfSpan,
} from '#cli/core/observability/perf-log'
import {
  isInterruptedStatusText,
  isTerminalStatusText,
  t,
} from '#cli/i18n'
import { isDelegationRecordedFinalMessage } from '#cli/compat/runtime-host'
import { normalizeAskUserQuestions } from '../../ask-user'
import { clearApprovalState, shouldAutoApproveRequest } from './approval-manager'
import { extractTextContent } from './content'
import { formatAgentErrorHintForUi } from './errors'
import { truncateReadToolOutput } from './read-output'
import { createSubagentRouter } from './stream-runner-subagents'
import {
  assistantHasMarkdownOutput,
  buildApprovalReason,
  extractToolCallId,
  formatInterruptedMarkdown,
  normalizeEventMetadata,
  normalizeFileRevert,
  normalizeToolName,
  waitOrTimeoutWithAbort,
} from './stream-runner-utils'

type ToolApprovalResult = Readonly<{ approved: boolean, reason?: string }>
type PendingApproval = Readonly<{
  request: ApprovalRequest
  assistantId: string
}>
interface RawToolCall {
  id?: string
  function?: {
    name?: string
    arguments?: unknown
    thoughtSignature?: unknown
  }
}

interface StreamRunnerUiDebug {
  lastRoundStartedAtMs?: number
  lastRoundFirstEventAtMs?: number
  lastRoundDoneAtMs?: number
  lastRoundHadError?: boolean
}

interface StreamRunnerUiState {
  debug?: StreamRunnerUiDebug
}

interface StreamRunnerStateLike {
  context: GoatChainContext
  sessionId: string
  messages: Message[]
  fileRevertsByToolCallId: Map<string, FileRevert>
  paused: PausedState | null
  approval: {
    active: ApprovalRequest | null
    pendingAutoRun: null | { assistantId: string, toolCallId: string }
    mode: ToolApprovalsMode
    queue: PendingApproval[]
    decisions: Record<string, ToolApprovalResult>
  }
  thinking: {
    active: boolean
    assistantId: string | null
    partId: string | null
  }
  runningAgentRound: boolean
  config?: RunConfig
  usage?: TokenUsage
  ui?: StreamRunnerUiState
}

type StreamRunnerDeps = Readonly<{
  state: StreamRunnerStateLike
  id: (prefix: string) => string
  streamIdleTimeoutMs: number
  streamTotalTimeoutMs: number
  pushMessage: (m: Message) => void
  updateAssistant: (
    messageId: string,
    fn: (m: Extract<Message, { role: 'assistant' }>) => Extract<Message, { role: 'assistant' }>,
  ) => void
  setAssistantStatus: (messageId: string, text: string) => void
  setAssistantThinking: (
    assistantId: string,
    content: string,
    partId: string,
  ) => void
  startNewThinkingBlock: (assistantId: string, partId: string) => void
  endThinkingBlock: (
    assistantId: string,
    partId: string,
    content?: string,
  ) => void
  appendAssistantMarkdown: (assistantId: string, delta: string) => void
  pushAssistantMarkdownPart: (assistantId: string, markdown: string) => void
  appendInfoPart: (assistantId: string, text: string) => void
  appendAssistantLlmMeta: (
    assistantId: string,
    info: Readonly<{
      durationMs: number
      modelId?: string
      toolCalls?: number
      tokensUsed?: number
      changedFiles?: readonly string[]
      changedFileDetails?: readonly LlmMetaChangedFile[]
      isFinalRound?: boolean
    }>,
  ) => void
  updateAssistantLlmMeta: (
    assistantId: string,
    info: Readonly<{
      durationMs?: number
      modelId?: string
      toolCalls?: number
      tokensUsed?: number
      changedFiles?: readonly string[]
      changedFileDetails?: readonly LlmMetaChangedFile[]
    }>,
  ) => void
  upsertToolCallDelta: (
    assistantId: string,
    callId: string,
    toolName: string | undefined,
    argsTextDelta: string | undefined,
    linesWritten?: number,
  ) => void
  upsertToolCallEnd: (
    assistantId: string,
    callId: string,
    toolName: string,
    args: Record<string, unknown>,
    thoughtSignature?: string,
  ) => void
  upsertToolOutputStart: (
    assistantId: string,
    toolCallId: string,
    toolName: string,
  ) => void
  appendToolOutputDelta: (
    assistantId: string,
    toolCallId: string,
    toolName: string,
    delta: string,
  ) => void
  insertApprovalPart: (assistantId: string, request: ApprovalRequest) => void
  attachToolResultPart: (assistantId: string, result: ToolResult) => void
  scheduleToolResultHighlight: (assistantId: string, result: ToolResult) => void
  upsertChecklistPartFromGoatChain: (
    assistantId: string,
    kind: 'todo' | 'plan',
    toolCallId: string,
    structured: unknown,
  ) => void
  applyLastRequestBudget: (budget: unknown) => void
  applyUsageFromSession: (usage: unknown) => void
  ensureErrorVisibleInTranscript: (
    assistantId: string,
    errorMeta: unknown,
  ) => void
}>

type StreamRunnerUiConfig = Readonly<{
  flushChars: number
  flushEvents: number
  yieldIntervalMs: number
}>

interface StreamCleanupState {
  timeoutMsOverride?: number
}

type ToolCallLookup = ReturnType<typeof createToolCallTranscriptLookup>

type RunAgentStreamLoopOptions = Readonly<{
  deps: StreamRunnerDeps
  assistantId: string
  stream: AsyncGenerator<ACPMessage, void, void>
  abort: { promise: Promise<void>, resolve: () => void }
  abortActiveAgentSignal: () => void
  toolCallIds: Set<string>
  toolCallLookup: ToolCallLookup
  findMostRecentExploreToolCallId: (
    match: Readonly<{ description: string, subagentType: string }>,
  ) => string | null
  thinkingState: ThinkingStateManager
  yieldToMacrotask: () => Promise<void>
  trace: (
    tag: string,
    message: string,
    payload?: Record<string, unknown>,
  ) => void
  ui: StreamRunnerUiConfig
  cleanupState: StreamCleanupState
  startedAt: number
}>

const MAX_CHANGED_FILE_DIFF_LINES = 2000
const MAX_CHANGED_FILE_DIFF_SIZE = 5_000_000

function toAbsoluteFilePath(filePath: string, cwd?: string): string {
  const raw = String(filePath ?? '').trim()
  if (!raw)
    return ''
  try {
    if (path.isAbsolute(raw))
      return path.normalize(raw)
    const cwdRaw = String(cwd ?? '').trim()
    if (!cwdRaw)
      return raw
    return path.resolve(cwdRaw, raw)
  }
  catch {
    return raw
  }
}

function utf8ByteLength(text: string): number {
  const buf = (globalThis as any)?.Buffer
  if (buf && typeof buf.byteLength === 'function')
    return buf.byteLength(text, 'utf8')

  let bytes = 0
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i)
    if (code < 0x80) {
      bytes += 1
      continue
    }
    if (code < 0x800) {
      bytes += 2
      continue
    }
    if (code >= 0xD800 && code <= 0xDBFF) {
      const next = text.charCodeAt(i + 1)
      if (next >= 0xDC00 && next <= 0xDFFF) {
        bytes += 4
        i++
      }
      else {
        bytes += 3
      }
      continue
    }
    if (code >= 0xDC00 && code <= 0xDFFF) {
      bytes += 3
      continue
    }
    bytes += 3
  }
  return bytes
}

function computeChangedLineStats(
  oldText: string,
  newText: string,
): { added: number, removed: number } | null {
  if (
    utf8ByteLength(oldText) > MAX_CHANGED_FILE_DIFF_SIZE
    || utf8ByteLength(newText) > MAX_CHANGED_FILE_DIFF_SIZE
  ) {
    return null
  }

  const oldLines = oldText.replace(/\r/g, '').split('\n')
  const newLines = newText.replace(/\r/g, '').split('\n')
  const m = oldLines.length
  const n = newLines.length
  if (m > MAX_CHANGED_FILE_DIFF_LINES || n > MAX_CHANGED_FILE_DIFF_LINES)
    return null

  const rowSize = n + 1
  const dp = new Uint16Array((m + 1) * rowSize)
  for (let i = 1; i <= m; i++) {
    const rowOffset = i * rowSize
    const prevRowOffset = (i - 1) * rowSize
    const oldLine = oldLines[i - 1]
    for (let j = 1; j <= n; j++) {
      const idx = rowOffset + j
      if (oldLine === newLines[j - 1])
        dp[idx] = dp[prevRowOffset + j - 1] + 1
      else dp[idx] = Math.max(dp[prevRowOffset + j], dp[rowOffset + j - 1])
    }
  }

  let i = m
  let j = n
  let added = 0
  let removed = 0
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
      i--
      j--
    }
    else if (
      j > 0
      && (i === 0 || dp[i * rowSize + (j - 1)] >= dp[(i - 1) * rowSize + j])
    ) {
      added++
      j--
    }
    else {
      removed++
      i--
    }
  }
  return { added, removed }
}

function summarizeChangedFile(
  entry: Readonly<{
    filePath: string
    previousContent?: string | null
    nextContent?: string | null
  }>,
  cwd?: string,
): LlmMetaChangedFile | null {
  const absPath = toAbsoluteFilePath(entry.filePath, cwd)
  if (!absPath)
    return null
  const stats = computeChangedLineStats(
    String(entry.previousContent ?? ''),
    String(entry.nextContent ?? ''),
  )
  return {
    path: absPath,
    ...(stats ? { added: stats.added, removed: stats.removed } : {}),
  }
}

function collectRoundChangedFileDetails(
  opts: Readonly<{
    toolCallIds: ReadonlySet<string>
    fileRevertsByToolCallId: ReadonlyMap<string, FileRevert>
    cwd?: string
  }>,
): LlmMetaChangedFile[] {
  if (opts.toolCallIds.size === 0 || opts.fileRevertsByToolCallId.size === 0)
    return []

  const out: LlmMetaChangedFile[] = []
  const seen = new Set<string>()
  for (const callId of opts.toolCallIds) {
    const entry = opts.fileRevertsByToolCallId.get(callId)
    const detail = entry ? summarizeChangedFile(entry, opts.cwd) : null
    const filePath = String(detail?.path ?? '').trim()
    if (!filePath || seen.has(filePath))
      continue
    seen.add(filePath)
    out.push(detail!)
  }
  return out
}

function mergeChangedFileDetails(
  primary: readonly LlmMetaChangedFile[],
  secondary: readonly LlmMetaChangedFile[],
): LlmMetaChangedFile[] {
  if (primary.length === 0)
    return [...secondary]
  if (secondary.length === 0)
    return [...primary]
  const out = new Map<string, LlmMetaChangedFile>()
  for (const item of primary) out.set(item.path, item)
  for (const item of secondary) {
    const prev = out.get(item.path)
    if (!prev) {
      out.set(item.path, item)
      continue
    }
    out.set(item.path, {
      path: item.path,
      ...(typeof prev.added === 'number'
        ? { added: prev.added }
        : typeof item.added === 'number'
          ? { added: item.added }
          : {}),
      ...(typeof prev.removed === 'number'
        ? { removed: prev.removed }
        : typeof item.removed === 'number'
          ? { removed: item.removed }
          : {}),
    })
  }
  return [...out.values()]
}

function ensureFileRevertsMap(state: unknown): Map<string, FileRevert> {
  const current = (state as any)?.fileRevertsByToolCallId
  if (current instanceof Map)
    return current as Map<string, FileRevert>
  const next = new Map<string, FileRevert>()
  try {
    ;(state as any).fileRevertsByToolCallId = next
  }
  catch {
    // Ignore assignment failures and still return a safe empty map.
  }
  return next
}

function collectAssistantToolCallIds(
  messages: readonly Message[],
  assistantId: string,
): string[] {
  const out: string[] = []
  const seen = new Set<string>()
  const msg = messages.find(m => m.id === assistantId)
  if (!msg || msg.role !== 'assistant')
    return out
  for (const part of msg.parts) {
    if (part.type !== 'tool_call')
      continue
    const id = String(part.call?.id ?? '').trim()
    if (!id || seen.has(id))
      continue
    seen.add(id)
    out.push(id)
  }
  return out
}

function readAssistantMarkdownText(
  messages: readonly Message[],
  assistantId: string,
): string {
  const assistant = messages.find(
    message => message.id === assistantId && message.role === 'assistant',
  ) as Extract<Message, { role: 'assistant' }> | undefined
  if (!assistant)
    return ''
  return assistant.parts
    .filter(
      (part): part is Extract<AssistantContentPart, { type: 'markdown' }> => part.type === 'markdown',
    )
    .map(part => String(part.markdown ?? ''))
    .join('')
}

function tryParseJsonObject(raw: string): Record<string, unknown> | null {
  const s = String(raw ?? '').trim()
  if (!s || !s.startsWith('{'))
    return null
  try {
    const parsed = JSON.parse(s)
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
      ? (parsed as Record<string, unknown>)
      : null
  }
  catch {
    return null
  }
}

function extractHookDisplayText(result: unknown): string {
  if (typeof result === 'string') {
    const text = result.trim()
    if (!text)
      return ''
    const parsed = tryParseJsonObject(text)
    const reason
      = parsed && typeof parsed.reason === 'string' ? parsed.reason.trim() : ''
    if (reason)
      return reason
    const message
      = parsed && typeof parsed.message === 'string' ? parsed.message.trim() : ''
    if (message)
      return message
    return text
  }

  if (result && typeof result === 'object') {
    const obj = result as Record<string, unknown>
    const reason = typeof obj.reason === 'string' ? obj.reason.trim() : ''
    if (reason)
      return reason
    const message = typeof obj.message === 'string' ? obj.message.trim() : ''
    if (message)
      return message
    try {
      return JSON.stringify(obj)
    }
    catch {
      return String(obj)
    }
  }

  return String(result ?? '').trim()
}

const MUTATING_TOOL_NAME_HINTS = ['edit', 'write', 'patch', 'replace'] as const
const MUTATING_TOOL_FILE_ARG_KEYS = [
  'file_path',
  'filePath',
  'path',
  'target_file',
  'targetPath',
  'filename',
  'file',
] as const
const TOOL_ARGS_FILE_PATH_RE
  = /"(?:file_path|filePath|path|target_file|targetPath|filename|file)"\s*:\s*"([^"]+)"/

function isLikelyFileMutatingTool(toolName: string): boolean {
  const lower = String(toolName ?? '')
    .trim()
    .toLowerCase()
  if (!lower)
    return false
  return MUTATING_TOOL_NAME_HINTS.some(hint => lower.includes(hint))
}

function extractFilePathFromToolArgs(args: Record<string, unknown>): string {
  for (const key of MUTATING_TOOL_FILE_ARG_KEYS) {
    const value = args[key]
    if (typeof value === 'string' && value.trim())
      return value.trim()
  }
  return ''
}

function extractFilePathFromToolCall(call: ToolCall): string {
  const args = call?.arguments
  if (args && typeof args === 'object' && !Array.isArray(args)) {
    const pathFromArgs = extractFilePathFromToolArgs(
      args as Record<string, unknown>,
    )
    if (pathFromArgs)
      return pathFromArgs
  }
  const argsText = String(call?.argumentsText ?? '')
  if (!argsText)
    return ''
  const matched = argsText.match(TOOL_ARGS_FILE_PATH_RE)
  if (!matched?.[1])
    return ''
  return String(matched[1]).trim()
}

function collectRoundChangedFilesFromToolCalls(
  opts: Readonly<{
    messages: readonly Message[]
    assistantId: string
    toolCallIds: readonly string[]
    cwd?: string
  }>,
): LlmMetaChangedFile[] {
  const msg = opts.messages.find(m => m.id === opts.assistantId)
  if (!msg || msg.role !== 'assistant')
    return []
  const idSet = new Set(
    opts.toolCallIds.map(id => String(id ?? '').trim()).filter(Boolean),
  )
  const out: LlmMetaChangedFile[] = []
  const seen = new Set<string>()
  for (const part of msg.parts) {
    if (part.type !== 'tool_call')
      continue
    const callId = String(part.call?.id ?? '').trim()
    if (idSet.size > 0 && !idSet.has(callId))
      continue
    if (!isLikelyFileMutatingTool(part.call?.name ?? ''))
      continue
    const filePath = toAbsoluteFilePath(
      extractFilePathFromToolCall(part.call),
      opts.cwd,
    )
    if (!filePath || seen.has(filePath))
      continue
    seen.add(filePath)
    out.push({ path: filePath })
  }
  return out
}

export async function runAgentStreamLoop(options: RunAgentStreamLoopOptions) {
  const {
    deps,
    assistantId,
    stream,
    abort,
    abortActiveAgentSignal,
    toolCallIds,
    toolCallLookup,
    findMostRecentExploreToolCallId,
    thinkingState,
    yieldToMacrotask,
    trace,
    ui,
    cleanupState,
    startedAt,
  } = options
  const opts = deps
  const NL = '\n'

  // Defined outside the streaming try block so the error handler can always
  // attempt a final UI flush, even if the stream setup fails early.
  let flushPendingUi: (opts2?: { force?: boolean }) => void = () => {}

  let lastTokensUsed = 0
  try {
    const dbg = opts.state.ui?.debug
    if (dbg && typeof dbg === 'object') {
      dbg.lastRoundStartedAtMs = startedAt
      dbg.lastRoundFirstEventAtMs = 0
      dbg.lastRoundDoneAtMs = 0
      dbg.lastRoundHadError = false
    }
  }
  catch {
    // ignore
  }
  let lastEventAt = startedAt
  let doneDrainSteps = 0
  // Track multiple thinking blocks with their individual buffers
  const thinkingBlocks = new Map<string, string>()
  let currentThinkingBlockOrdinal = -1
  let currentThinkingPartId: string | null = null
  let pendingThinkingPartId: string | null = null
  let pendingThinkingAssistantId: string | null = null
  let lastUiYieldAt = startedAt
  let lastDebugStatsAt = startedAt
  const streamStats: {
    counts: Record<string, number>
    textChars: number
    thinkingChars: number
    toolArgsChars: number
    toolOutputChars: number
  } = {
    counts: {},
    textChars: 0,
    thinkingChars: 0,
    toolArgsChars: 0,
    toolOutputChars: 0,
  }

  // Convenience functions for backward compatibility
  const startThinking = (): void =>
    thinkingState.start(currentThinkingPartId ?? undefined)
  const endThinking = (): void => thinkingState.end()

  // Auto-approval wait tracking - moved outside loop to persist across iterations
  let autoApprovalWaitStart: number | null = null
  const AUTO_APPROVE_MAX_WAIT_MS = 500 // Increased from 200ms
  const AUTO_APPROVE_CHECK_INTERVAL_MS = 50 // Base check interval

  // Done drain tracking for intelligent post-done event handling
  let doneReceivedAt: number | null = null
  let lastDoneModelId: string | undefined
  const DONE_DRAIN_MAX_DURATION_MS = 1000 // Drain for max 1 second after done
  const DONE_DRAIN_MAX_STEPS = 32 // Reduced from 64
  let compressionTranscriptAssistantId: string | null = null

  /**
   * Priority of events to process after done is received.
   * Higher number = higher priority.
   */
  function getPostDoneEventPriority(eventType: string | null): number {
    const priorityMap: Record<string, number> = {
      tool_result: 10, // Tool results are critical
      tool_output_delta: 9, // Tool output streaming
      tool_output_start: 8, // Tool output starting
      error: 7, // Errors
      done: 5, // Additional done events
      compression_end: 6, // Compression info should be preserved
      compression_start: 6, // Compression start should be preserved
    }
    return priorityMap[eventType || ''] ?? 0
  }

  function appendRoundLlmMeta(
    opts2?: Readonly<{ modelId?: string, allowToolOnly?: boolean }>,
  ): void {
    const msg = opts.state.messages.find(m => m.id === assistantId)
    if (!msg || msg.role !== 'assistant')
      return

    if (msg.parts.some(p => p.type === 'llm_meta'))
      return

    const allowToolOnly = Boolean(opts2?.allowToolOnly)
    if (!allowToolOnly) {
      const hasUserFacingContent = msg.parts.some((part) => {
        if (part.type === 'markdown')
          return Boolean(String(part.markdown ?? '').trim())
        if (part.type === 'info')
          return Boolean(String(part.text ?? '').trim())
        return (
          part.type === 'todo'
          || part.type === 'plan'
          || part.type === 'approve'
        )
      })
      if (!hasUserFacingContent)
        return
    }

    const toolCallIdsForRound = (() => {
      const fromMessage = collectAssistantToolCallIds(
        opts.state.messages,
        assistantId,
      )
      if (fromMessage.length > 0)
        return fromMessage
      return Array.from(toolCallIds)
    })()
    const changedFileDetailsFromRevert = collectRoundChangedFileDetails({
      toolCallIds: new Set(toolCallIdsForRound),
      fileRevertsByToolCallId: ensureFileRevertsMap(opts.state),
      cwd: String(opts.state.context?.cwd ?? ''),
    })
    const changedFileDetailsFromToolCalls
      = collectRoundChangedFilesFromToolCalls({
        messages: opts.state.messages,
        assistantId,
        toolCallIds: toolCallIdsForRound,
        cwd: String(opts.state.context?.cwd ?? ''),
      })
    const changedFileDetails = mergeChangedFileDetails(
      changedFileDetailsFromRevert,
      changedFileDetailsFromToolCalls,
    )
    const changedFiles = changedFileDetails.map(item => item.path)
    const tokensUsed = Math.max(
      0,
      Math.floor(Number(opts.state.usage?.total_tokens ?? 0)),
    )
    const modelId = String(opts2?.modelId ?? '').trim() || undefined
    const durationEndAt = doneReceivedAt ?? Date.now()
    opts.appendAssistantLlmMeta(assistantId, {
      durationMs: Math.max(0, durationEndAt - startedAt),
      ...(modelId ? { modelId } : {}),
      toolCalls: toolCallIdsForRound.length,
      tokensUsed,
      ...(changedFiles.length > 0 ? { changedFiles } : {}),
      ...(changedFileDetails.length > 0 ? { changedFileDetails } : {}),
      isFinalRound: true,
    })
  }

  function ensureCompressionTranscriptMessage(text: string): string | null {
    const nextText = String(text ?? '').trim()
    if (!nextText)
      return null

    const existingId = compressionTranscriptAssistantId
    if (existingId)
      return existingId

    const messageId = `compression-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
    const modelId = opts.state.config?.model?.trim() || undefined
    const msg: Extract<Message, { role: 'assistant' }> = {
      id: messageId,
      role: 'assistant',
      createdAtMs: Date.now(),
      parts: [
        { type: 'status', text: '' },
        { type: 'markdown', markdown: nextText },
      ],
      ...(modelId ? { modelId } : {}),
    }
    const assistantIndex = opts.state.messages.findIndex(
      m => m.id === assistantId,
    )
    if (assistantIndex >= 0)
      opts.state.messages.splice(assistantIndex, 0, msg)
    else opts.pushMessage(msg)
    compressionTranscriptAssistantId = messageId
    return messageId
  }

  function assistantMessageHasRenderableContent(
    message: Extract<Message, { role: 'assistant' }>,
  ): boolean {
    return message.parts.some((part) => {
      if (part.type !== 'status')
        return true
      return Boolean(String(part.text ?? '').trim())
    })
  }

  function insertStreamUserMessage(
    content: string,
    opts2?: Readonly<{
      splitAssistant?: boolean
      metadata?: Record<string, unknown>
    }>,
  ): void {
    const nextContent = String(content ?? '')
    if (!nextContent)
      return

    const userMessage: Extract<Message, { role: 'user' }> = {
      id: opts.id('m'),
      role: 'user',
      content: nextContent,
      createdAtMs: Date.now(),
      ...(opts2?.metadata ? { metadata: { ...opts2.metadata } } : {}),
    }
    opts.pushMessage(userMessage)

    const appendedIndex = opts.state.messages.findIndex(
      m => m.id === userMessage.id,
    )
    if (appendedIndex < 0)
      return

    const assistantIndex = opts.state.messages.findIndex(
      m => m.id === assistantId && m.role === 'assistant',
    )
    if (assistantIndex < 0 || appendedIndex <= assistantIndex)
      return

    const [insertedUser] = opts.state.messages.splice(appendedIndex, 1)
    if (!insertedUser)
      return

    const currentAssistant = opts.state.messages[assistantIndex]
    if (
      !opts2?.splitAssistant
      || !currentAssistant
      || currentAssistant.role !== 'assistant'
    ) {
      opts.state.messages.splice(assistantIndex, 0, insertedUser)
      return
    }

    if (!assistantMessageHasRenderableContent(currentAssistant)) {
      opts.state.messages.splice(assistantIndex, 0, insertedUser)
      return
    }

    const historicalAssistant: Extract<Message, { role: 'assistant' }> = {
      ...currentAssistant,
      id: opts.id('m'),
      parts: [...currentAssistant.parts],
    }
    const continuedAssistant: Extract<Message, { role: 'assistant' }> = {
      id: assistantId,
      role: 'assistant',
      parts: [{ type: 'status', text: '' }],
      createdAtMs: Date.now(),
      ...(currentAssistant.modelId
        ? { modelId: currentAssistant.modelId }
        : {}),
    }

    opts.state.messages.splice(
      assistantIndex,
      1,
      historicalAssistant,
      insertedUser,
      continuedAssistant,
    )
    currentThinkingBlockOrdinal = -1
    currentThinkingPartId = null
    thinkingBlocks.clear()
    pendingThinkingPartId = null
    pendingThinkingAssistantId = null
  }

  function appendCompressionTranscript(text: string): void {
    const nextText = String(text ?? '').trim()
    if (!nextText)
      return

    const targetId = ensureCompressionTranscriptMessage(
      t('chatPage.toast.compactingSession'),
    )
    if (!targetId)
      return

    const target = opts.state.messages.find(
      m => m.id === targetId && m.role === 'assistant',
    ) as Extract<Message, { role: 'assistant' }> | undefined
    const lastMarkdown = target
      ? [...target.parts]
          .reverse()
          .find(
            (
              part,
            ): part is Extract<
              (typeof target.parts)[number],
              { type: 'markdown' }
            > => part.type === 'markdown',
          )
      : undefined
    const existingText = String(lastMarkdown?.markdown ?? '').trim()
    if (!existingText) {
      opts.appendAssistantMarkdown(targetId, nextText)
      return
    }
    if (existingText === nextText || existingText.endsWith(`\n${nextText}`))
      return
    opts.appendAssistantMarkdown(targetId, `\n${nextText}`)
  }

  let pendingAssistantMarkdownChunks: string[] = []
  let pendingAssistantMarkdownChars = 0
  const pendingToolOutputByCallId = new Map<
    string,
    { assistantId: string, toolName: string, chunks: string[], chars: number }
  >()
  const pendingToolArgsByCallId = new Map<
    string,
    {
      assistantId: string
      toolName: string | undefined
      chunks: string[]
      chars: number
      linesWritten?: number
    }
  >()
  let pendingEvents = 0
  let lastUiFlushAt = startedAt
  let flushTimer: ReturnType<typeof setTimeout> | null = null

  const queueToolOutputDelta = (entry: {
    assistantId: string
    toolCallId: string
    toolName: string
    delta: string
  }): void => {
    const toolCallId = String(entry.toolCallId ?? '').trim()
    const toolName = normalizeToolName(entry.toolName)
    const assistantId = String(entry.assistantId ?? '').trim()
    const delta = String(entry.delta ?? '')
    if (!toolCallId || !assistantId || !delta)
      return

    const prev = pendingToolOutputByCallId.get(toolCallId)
    if (
      prev
      && prev.assistantId === assistantId
      && prev.toolName === toolName
    ) {
      prev.chunks.push(delta)
      prev.chars += delta.length
    }
    else if (prev) {
      flushPendingUi({ force: true })
      pendingToolOutputByCallId.set(toolCallId, {
        assistantId,
        toolName,
        chunks: [delta],
        chars: delta.length,
      })
    }
    else {
      pendingToolOutputByCallId.set(toolCallId, {
        assistantId,
        toolName,
        chunks: [delta],
        chars: delta.length,
      })
    }
    pendingEvents += 1
  }

  flushPendingUi = (opts2?: { force?: boolean }): void => {
    const force = Boolean(opts2?.force)
    if (!force && pendingEvents <= 0)
      return

    return withPerfSpan(
      'flushPendingUi',
      `force=${force ? 1 : 0} pendingEvents=${pendingEvents} mdChars=${pendingAssistantMarkdownChars} toolArgs=${pendingToolArgsByCallId.size} toolOut=${pendingToolOutputByCallId.size} thinking=${pendingThinkingPartId ? 1 : 0}`,
      () => {
        trace('STREAM', 'flush.start', {
          assistantId,
          force,
          pendingEvents,
          pendingAssistantMarkdownChars,
          pendingToolArgs: pendingToolArgsByCallId.size,
          pendingToolOut: pendingToolOutputByCallId.size,
          pendingThinking: pendingThinkingPartId ? 1 : 0,
        })
        if (pendingAssistantMarkdownChunks.length > 0) {
          const joined
            = pendingAssistantMarkdownChunks.length === 1
              ? pendingAssistantMarkdownChunks[0]!
              : pendingAssistantMarkdownChunks.join('')
          trace('STREAM', 'flush.markdown', {
            assistantId,
            chars: joined.length,
          })
          opts.appendAssistantMarkdown(assistantId, joined)
          pendingAssistantMarkdownChunks = []
          pendingAssistantMarkdownChars = 0
        }

        if (pendingToolArgsByCallId.size > 0) {
          for (const [callId, entry] of pendingToolArgsByCallId.entries()) {
            const shouldFlush
              = entry.chars > 0 || typeof entry.linesWritten === 'number'
            if (!shouldFlush)
              continue
            const joined
              = entry.chunks.length === 1
                ? entry.chunks[0]!
                : entry.chunks.join('')
            trace('STREAM', 'flush.tool_call_delta', {
              assistantId: entry.assistantId,
              callId,
              toolName: entry.toolName ?? '',
              chars: joined.length,
            })
            opts.upsertToolCallDelta(
              entry.assistantId,
              callId,
              entry.toolName,
              joined,
              entry.linesWritten,
            )
          }
          pendingToolArgsByCallId.clear()
        }

        if (pendingToolOutputByCallId.size > 0) {
          for (const [callId, entry] of pendingToolOutputByCallId.entries()) {
            if (entry.chars > 0) {
              const joined
                = entry.chunks.length === 1
                  ? entry.chunks[0]!
                  : entry.chunks.join('')
              trace('STREAM', 'flush.tool_output_delta', {
                assistantId: entry.assistantId,
                callId,
                toolName: entry.toolName,
                chars: joined.length,
              })
              opts.appendToolOutputDelta(
                entry.assistantId,
                callId,
                entry.toolName,
                joined,
              )
            }
          }
          pendingToolOutputByCallId.clear()
        }

        if (pendingThinkingPartId && pendingThinkingAssistantId) {
          const partId = pendingThinkingPartId
          const tid = pendingThinkingAssistantId
          pendingThinkingPartId = null
          pendingThinkingAssistantId = null
          const content = thinkingBlocks.get(partId) ?? ''
          trace('STREAM', 'flush.thinking', {
            assistantId: tid,
            partId,
            chars: content.length,
          })
          opts.setAssistantThinking(tid, content, partId)
        }

        pendingEvents = 0
        lastUiFlushAt = Date.now()
        if (flushTimer) {
          clearTimeout(flushTimer)
          flushTimer = null
        }
        trace('STREAM', 'flush.end', { assistantId })
      },
    )
  }

  const replaceAssistantMarkdownWithSettledText = (
    targetAssistantId: string,
    text: string,
  ): void => {
    const settledText = String(text ?? '')
    if (!settledText)
      return
    flushPendingUi({ force: true })
    const previousText = readAssistantMarkdownText(
      opts.state.messages as Message[],
      targetAssistantId,
    )
    if (previousText === settledText)
      return
    opts.updateAssistant(targetAssistantId, (message) => {
      const nextParts = message.parts.filter(part => part.type !== 'markdown')
      return nextParts.length === message.parts.length
        ? message
        : { ...message, parts: nextParts }
    })
    opts.appendAssistantMarkdown(targetAssistantId, settledText)
  }

  const scheduleFlushSoon = (): void => {
    if (flushTimer || pendingEvents <= 0)
      return
    // Keep the UI moving even if the agent goes idle right after a small delta burst.
    // Cap to avoid long stalls if the adaptive interval grows.
    const delay = Math.min(50, Math.max(1, Math.floor(ui.yieldIntervalMs)))
    flushTimer = setTimeout(() => {
      flushTimer = null
      if (pendingEvents > 0)
        flushPendingUi({ force: true })
    }, delay)
    if (flushTimer) {
      const nodeTimer = flushTimer as { unref?: () => void }
      nodeTimer.unref?.()
    }
  }

  const maybeFlushAndYieldUi = async (opts2?: {
    force?: boolean
  }): Promise<void> => {
    const now = Date.now()
    const shouldYield
      = Boolean(opts2?.force) || now - lastUiYieldAt >= ui.yieldIntervalMs
    const shouldFlushByTime
      = pendingEvents > 0 && now - lastUiFlushAt >= ui.yieldIntervalMs
    const shouldFlush
      = Boolean(opts2?.force)
        || shouldYield
        || shouldFlushByTime
        || pendingEvents >= ui.flushEvents
        || pendingAssistantMarkdownChars >= ui.flushChars

    if (shouldFlush)
      flushPendingUi({ force: true })
    else scheduleFlushSoon()

    if (shouldYield) {
      lastUiYieldAt = now
      trace('STREAM', 'yield.flush', {
        assistantId,
        force: Boolean(opts2?.force),
        shouldFlush: shouldFlush ? 1 : 0,
        shouldFlushByTime: shouldFlushByTime ? 1 : 0,
        shouldYield: 1,
        pendingEvents,
        pendingAssistantMarkdownChars,
        pendingToolArgs: pendingToolArgsByCallId.size,
        pendingToolOut: pendingToolOutputByCallId.size,
        intervalMs: ui.yieldIntervalMs,
      })
      await yieldToMacrotask()
    }
  }

  const bump = (key: string, delta = 1): void => {
    const k = String(key ?? '').trim()
    if (!k)
      return
    streamStats.counts[k] = (streamStats.counts[k] ?? 0) + delta
  }

  const maybeLogStreamStats = (opts2?: { force?: boolean }): void => {
    const now = Date.now()
    const should = Boolean(opts2?.force) || now - lastDebugStatsAt >= 1000
    if (!should)
      return
    lastDebugStatsAt = now
    debugLog('agent_round_stats', {
      session_id: String(opts.state.sessionId ?? ''),
      counts: streamStats.counts,
      textChars: streamStats.textChars,
      thinkingChars: streamStats.thinkingChars,
      toolArgsChars: streamStats.toolArgsChars,
      toolOutputChars: streamStats.toolOutputChars,
    })
  }

  const subagentRouter = createSubagentRouter({
    toolCallLookup,
    findMostRecentExploreToolCallId,
    upsertToolOutputStart: opts.upsertToolOutputStart,
    appendToolOutputDelta: opts.appendToolOutputDelta,
    queueToolOutputDelta,
    flushPendingUi,
    maybeFlushAndYieldUi,
    maybeLogStreamStats,
  })

  const waitOrTimeout = async <T>(
    p: Promise<T>,
    ms: number,
  ): Promise<
    { kind: 'value', value: T } | { kind: 'timeout' } | { kind: 'abort' }
  > => {
    return waitOrTimeoutWithAbort(p, ms, abort.promise)
  }

  while (true) {
    const now = Date.now()
    const totalLeft = opts.streamTotalTimeoutMs - (now - startedAt)
    // After receiving done event, use extended idle timeout for tool completion
    const idleTimeoutMs = doneReceivedAt
      ? Math.max(opts.streamIdleTimeoutMs, DONE_DRAIN_MAX_DURATION_MS * 2)
      : opts.streamIdleTimeoutMs
    const idleLeft = idleTimeoutMs - (now - lastEventAt)
    const pendingReq = opts.state.approval.active
    const isAutoApprovePending
      = Boolean(pendingReq)
        && pendingReq!.status === 'pending'
        && opts.state.paused?.kind === 'tool_approval'
        && shouldAutoApproveRequest(opts.state.approval.mode, pendingReq!)

    // Smart auto-approval wait strategy with progressive backoff
    let waitMs: number
    if (isAutoApprovePending) {
      // Track when we started waiting for auto-approval
      if (!autoApprovalWaitStart) {
        autoApprovalWaitStart = now
      }

      // Use progressive backoff for auto-approval checks
      const waitingSince = now - autoApprovalWaitStart
      const checkInterval = Math.min(
        AUTO_APPROVE_CHECK_INTERVAL_MS * (1 + Math.floor(waitingSince / 200)),
        AUTO_APPROVE_CHECK_INTERVAL_MS * 4,
      )
      waitMs = Math.min(Math.min(totalLeft, idleLeft), checkInterval)
    }
    else {
      // Reset auto-approval wait tracker when not pending
      if (autoApprovalWaitStart) {
        debugLog('auto_approve_wait_end', {
          session_id: String(opts.state.sessionId ?? ''),
          waited_ms: now - autoApprovalWaitStart,
        })
        autoApprovalWaitStart = null
      }
      waitMs = Math.min(totalLeft, idleLeft)
    }

    trace('STREAM', 'wait.next', {
      assistantId,
      waitMs,
      idleLeft,
      totalLeft,
      isAutoApprovePending: isAutoApprovePending ? 1 : 0,
      doneReceivedAt: doneReceivedAt ?? null,
    })
    // IMPORTANT: `stream.next()` (async generator) can do substantial synchronous work
    // before returning/awaiting, which would not be attributed to our code unless we
    // wrap it. This span helps correlate multi-second event-loop stalls to the stream
    // implementation itself.
    const spanDetail = `waitMs=${Math.max(0, Math.floor(waitMs))} pendingEvents=${pendingEvents} mdChars=${pendingAssistantMarkdownChars} toolArgs=${pendingToolArgsByCallId.size} toolOut=${pendingToolOutputByCallId.size}`
    pushPerfSpan('agentStream.next', spanDetail)
    let next: Awaited<
      ReturnType<typeof waitOrTimeout<IteratorResult<ACPMessage>>>
    > | null = null
    try {
      next = await waitOrTimeout(stream.next(), waitMs)
    }
    finally {
      popPerfSpan('agentStream.next')
    }
    if (next.kind === 'abort') {
      // Flush any buffered UI content before appending the interrupted block.
      flushPendingUi({ force: true })
      // Clear thinking state on user interrupt
      thinkingState.forceEnd()
      cleanupState.timeoutMsOverride = 200

      opts.appendAssistantMarkdown(
        assistantId,
        formatInterruptedMarkdown({
          appendToExisting: assistantHasMarkdownOutput(
            opts.state.messages,
            assistantId,
          ),
        }),
      )
      opts.state.paused = null
      clearApprovalState(opts.state.approval)
      opts.setAssistantStatus(assistantId, t('chatStatus.interrupted'))
      break
    }
    if (next.kind === 'timeout') {
      // Enhanced timeout handling for auto-approval scenarios
      if (isAutoApprovePending) {
        const waitedSince = autoApprovalWaitStart
          ? now - autoApprovalWaitStart
          : 0
        // Only break if we've waited beyond threshold or streams are truly idle
        if (waitedSince > AUTO_APPROVE_MAX_WAIT_MS || idleLeft <= 0) {
          debugLog('auto_approve_timeout_trigger_resume', {
            session_id: String(opts.state.sessionId ?? ''),
            waited_ms: waitedSince,
            idle_left_ms: idleLeft,
            total_left_ms: totalLeft,
          })
          // Exit gracefully to let store's auto-approval logic take over
          // Don't clear paused/approval state - the store will handle it
          opts.setAssistantStatus(assistantId, t('chatStatus.awaitingApproval'))
          break
        }
        else {
          // Continue waiting for auto-approval
          continue
        }
      }

      // After done event, be more lenient with timeouts during drain phase
      if (doneReceivedAt) {
        const drainElapsed = now - doneReceivedAt
        if (drainElapsed <= DONE_DRAIN_MAX_DURATION_MS * 2) {
          debugLog('timeout_during_done_drain_extend', {
            session_id: String(opts.state.sessionId ?? ''),
            drain_elapsed_ms: drainElapsed,
            idle_left_ms: idleLeft,
            total_left_ms: totalLeft,
          })
          // Continue waiting - we're in the drain phase after done
          continue
        }
      }

      const env = opts.state.context?.env
      const errText = [
        'Agent stream timed out.',
        '',
        `idle_timeout_ms=${opts.streamIdleTimeoutMs}`,
        `total_timeout_ms=${opts.streamTotalTimeoutMs}`,
        ...(doneReceivedAt
          ? [
              `done_received_at=${new Date(doneReceivedAt).toISOString()}`,
              `drain_elapsed_ms=${now - doneReceivedAt}`,
            ]
          : []),
      ].join(NL)
      const hint
        = formatAgentErrorHintForUi(errText, env)
          || 'Hint: try /compact, or start a new session with /new.'
      // Ensure any buffered output lands before the timeout error block.
      flushPendingUi({ force: true })
      opts.pushAssistantMarkdownPart(
        assistantId,
        [
          '# Error',
          '',
          '```text',
          errText,
          '```',
          '',
          hint,
        ].join(NL),
      )

      // COMPREHENSIVE CLEANUP: Clear all state to prevent inconsistencies
      opts.state.paused = null
      clearApprovalState(opts.state.approval)

      // Clear thinking state immediately to prevent stuck "Thinking..." UI
      thinkingState.forceEnd()

      // Cancel any pending operations by aborting the stream
      abortActiveAgentSignal()
      cleanupState.timeoutMsOverride = 200

      debugLog('agent_round_timeout', {
        session_id: String(opts.state.sessionId ?? ''),
        idle_timeout_ms: opts.streamIdleTimeoutMs,
        total_timeout_ms: opts.streamTotalTimeoutMs,
        thinking_was_active: thinkingState.isThinking,
        auto_approve_waited_ms: autoApprovalWaitStart
          ? now - autoApprovalWaitStart
          : undefined,
        done_received: doneReceivedAt !== null,
        drain_elapsed_ms: doneReceivedAt ? now - doneReceivedAt : undefined,
        drain_steps: doneDrainSteps,
      })

      opts.setAssistantStatus(assistantId, t('chatStatus.error'))
      break
    }

    const { value, done } = next.value
    if (done)
      break

    const msg = value
    lastEventAt = Date.now()
    try {
      const dbg = opts.state.ui?.debug
      if (dbg && typeof dbg === 'object' && !dbg.lastRoundFirstEventAtMs)
        dbg.lastRoundFirstEventAtMs = lastEventAt
    }
    catch {
      // ignore
    }

    // Normalize metadata to handle field name variations from upstream SDK
    const md
      = normalizeEventMetadata(
        (msg as { metadata?: StreamEventMetadata }).metadata,
      ) ?? {}

    const metaSessionId = String(md.session_id ?? '').trim()
    if (metaSessionId && metaSessionId !== opts.state.sessionId)
      opts.state.sessionId = metaSessionId
    if (md.usage)
      opts.applyUsageFromSession(md.usage)
    if (md.lastRequestBudget)
      opts.applyLastRequestBudget(md.lastRequestBudget)
    if (doneReceivedAt) {
      const tokensUsed = Math.max(
        0,
        Math.floor(Number(opts.state.usage?.total_tokens ?? 0)),
      )
      const nextTokensUsed = tokensUsed
      if (nextTokensUsed > lastTokensUsed) {
        lastTokensUsed = nextTokensUsed
        opts.updateAssistantLlmMeta(assistantId, { tokensUsed: nextTokensUsed })
      }
    }
    const eventType
      = getOriginalEventType(msg) ?? String(md.originalEventType ?? '')
    trace('EVT', 'recv', {
      assistantId,
      role: msg.role,
      eventType,
      metaKeys: typeof md === 'object' ? Object.keys(md).length : 0,
      pendingEvents,
      pendingAssistantMarkdownChars,
      pendingToolArgs: pendingToolArgsByCallId.size,
      pendingToolOut: pendingToolOutputByCallId.size,
    })
    bump(eventType || `${msg.role}_message`)

    // Some providers may not emit an explicit thinking_end boundary before
    // switching into tool-call/control events. End thinking as soon as we
    // observe any non-thinking event so the UI pulse does not linger until done.
    const isThinkingEvent
      = eventType === 'thinking_start'
        || eventType === 'thinking_delta'
        || eventType === 'thinking_end'
    if (opts.state.thinking.active && !isThinkingEvent)
      endThinking()

    const subagentHandled = await subagentRouter.handleSubagentEvent({
      msg,
      md,
      assistantId,
    })
    if (subagentHandled)
      continue

    if (eventType === 'compression_start') {
      trace('EVT', 'compression_start', {
        assistantId,
        tokensBefore: md?.tokensBefore,
        threshold: md?.threshold,
      })
      flushPendingUi({ force: true })
      ensureCompressionTranscriptMessage(t('chatPage.toast.compactingSession'))
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }
    if (eventType === 'compression_end') {
      const tokensBefore = Number(md?.tokensBefore ?? 0)
      const tokensAfter = Number(md?.tokensAfter ?? 0)
      const removed = Number(md?.removedMessages ?? 0)
      const saved = tokensBefore - tokensAfter
      const elapsed = Number(md?.elapsedMs ?? 0)
      trace('EVT', 'compression_end', {
        assistantId,
        tokensBefore,
        tokensAfter,
        removed,
        saved,
        elapsed,
      })
      flushPendingUi({ force: true })
      appendCompressionTranscript(
        t('chatStatus.compressed', {
          removed: String(removed),
          saved: String(saved),
          elapsed: String(elapsed),
        }),
      )
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }

    if (eventType === 'hook_evaluation') {
      const phase = String((md as any)?.hookPhase ?? (md as any)?.phase ?? '')
        .trim()
        .toLowerCase()
      if (phase !== 'end') {
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
        continue
      }

      const toolCallId = String(
        (md as any)?.toolCallId ?? (md as any)?.tool_call_id ?? '',
      ).trim()
      if (!toolCallId) {
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
        continue
      }

      const hookResult
        = (md as any)?.hookResult
          ?? (md as any)?.result
          ?? extractTextContent(msg.content)
      const hookText = extractHookDisplayText(hookResult)
      if (!hookText) {
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
        continue
      }

      const found = toolCallLookup.findCallInfo(toolCallId)
      const targetAssistantId = found?.assistantId ?? assistantId
      const shouldRenderNow = !assistantHasMarkdownOutput(
        opts.state.messages,
        targetAssistantId,
      )
      if (shouldRenderNow) {
        flushPendingUi({ force: true })
        opts.appendInfoPart(
          targetAssistantId,
          formatHookEvaluationInfoText(hookText),
        )
        await maybeFlushAndYieldUi({ force: true })
      }
      else {
        await maybeFlushAndYieldUi()
      }
      maybeLogStreamStats()
      continue
    }

    if (eventType === 'tool_output_start') {
      const toolCallId = extractToolCallId(md, {
        eventType,
        assistantId,
        sessionId: String(opts.state.sessionId ?? ''),
      })
      if (toolCallId) {
        const found = toolCallLookup.findCallInfo(toolCallId)
        const toolName = normalizeToolName(
          md?.toolName ?? md?.tool_name ?? found?.toolName,
        )
        const targetAssistantId = found?.assistantId ?? assistantId
        trace('EVT', 'tool_output_start', {
          assistantId: targetAssistantId,
          toolCallId,
          toolName,
        })
        // Keep ordering: ensure any buffered output deltas land after the start marker.
        flushPendingUi({ force: true })
        opts.upsertToolOutputStart(targetAssistantId, toolCallId, toolName)
      }
      // tool_output_start mutates transcript but does not increment pendingEvents;
      // force a yield so the header shows up before deltas (and to avoid bursty UI).
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }
    if (eventType === 'tool_output_delta') {
      const toolCallId = extractToolCallId(md, {
        eventType,
        assistantId,
        sessionId: String(opts.state.sessionId ?? ''),
      })
      if (toolCallId) {
        const found = toolCallLookup.findCallInfo(toolCallId)
        const toolName = normalizeToolName(
          md?.toolName ?? md?.tool_name ?? found?.toolName,
        )
        const targetAssistantId = found?.assistantId ?? assistantId
        const delta = String(md?.delta ?? extractTextContent(msg.content) ?? '')
        if (delta) {
          trace('EVT', 'tool_output_delta', {
            assistantId: targetAssistantId,
            toolCallId,
            toolName,
            deltaChars: delta.length,
          })
          const prev = pendingToolOutputByCallId.get(toolCallId)
          if (
            prev
            && prev.assistantId === targetAssistantId
            && prev.toolName === toolName
          ) {
            prev.chunks.push(delta)
            prev.chars += delta.length
          }
          else if (prev) {
            flushPendingUi({ force: true })
            pendingToolOutputByCallId.set(toolCallId, {
              assistantId: targetAssistantId,
              toolName,
              chunks: [delta],
              chars: delta.length,
            })
          }
          else {
            pendingToolOutputByCallId.set(toolCallId, {
              assistantId: targetAssistantId,
              toolName,
              chunks: [delta],
              chars: delta.length,
            })
          }
          streamStats.toolOutputChars += delta.length
          pendingEvents += 1
          // Coalesce tool output streaming into bounded chunks to avoid blocking the UI.
          await maybeFlushAndYieldUi()
          maybeLogStreamStats()
        }
      }
      else {
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
      }
      continue
    }

    if (eventType === 'tool_call_start') {
      const callId = String(md?.callId ?? '').trim()
      if (callId) {
        toolCallIds.add(callId)
        const toolName = normalizeToolName(md?.toolName)
        trace('EVT', 'tool_call_start', { assistantId, callId, toolName })
        // Keep ordering: ensure any buffered markdown lands before the tool call header.
        flushPendingUi({ force: true })
        // Render the header immediately (even if args deltas haven't arrived yet).
        opts.upsertToolCallDelta(assistantId, callId, toolName, '', undefined)
      }
      // tool_call_start mutates transcript but does not increment pendingEvents;
      // force a yield so the header shows up immediately (and to avoid bursty UI).
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }
    if (eventType === 'thinking_start') {
      // Create a new thinking block for each thinking_start event
      currentThinkingBlockOrdinal += 1
      currentThinkingPartId = createLocalThinkingPartId(
        assistantId,
        currentThinkingBlockOrdinal,
      )
      thinkingBlocks.set(currentThinkingPartId, '')
      opts.startNewThinkingBlock(assistantId, currentThinkingPartId)
      startThinking()
      // thinking_start mutates transcript but does not increment pendingEvents;
      // force a yield so the UI can render the new block immediately.
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }
    if (eventType === 'thinking_delta') {
      startThinking()
      const delta = extractTextContent(msg.content)
      if (delta && currentThinkingPartId) {
        const nextContent = `${thinkingBlocks.get(currentThinkingPartId) ?? ''}${delta}`
        thinkingBlocks.set(currentThinkingPartId, nextContent)
        pendingThinkingAssistantId = assistantId
        pendingThinkingPartId = currentThinkingPartId
        streamStats.thinkingChars += delta.length
        pendingEvents += 1
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
      }
      continue
    }
    if (eventType === 'thinking_end') {
      flushPendingUi({ force: true })
      endThinking()
      const text = extractTextContent(msg.content)
      if (text && currentThinkingPartId) {
        const currentBuffer = thinkingBlocks.get(currentThinkingPartId) ?? ''
        if (text && text !== currentBuffer) {
          if (currentBuffer && text.startsWith(currentBuffer))
            thinkingBlocks.set(currentThinkingPartId, text)
          else if (currentBuffer && !currentBuffer.startsWith(text))
            thinkingBlocks.set(currentThinkingPartId, `${currentBuffer}${text}`)
          else if (!currentBuffer)
            thinkingBlocks.set(currentThinkingPartId, text)
        }
        opts.endThinkingBlock(
          assistantId,
          currentThinkingPartId,
          thinkingBlocks.get(currentThinkingPartId),
        )
      }
      await maybeFlushAndYieldUi()
      maybeLogStreamStats()
      continue
    }

    if (eventType === 'tool_call_delta') {
      const callId = String(md?.callId ?? '').trim()
      const delta = String(md?.argsTextDelta ?? '')
      const toolName = md.toolName
      const linesWritten = (() => {
        const raw = (md as any)?.linesWritten ?? (md as any)?.lines_written
        const n = Number(raw)
        if (!Number.isFinite(n))
          return null
        return Math.max(0, Math.floor(n))
      })()
      if (callId && (delta || typeof linesWritten === 'number')) {
        toolCallIds.add(callId)
        trace('EVT', 'tool_call_delta', {
          assistantId,
          callId,
          toolName: String(toolName ?? ''),
          deltaChars: delta.length,
        })
        const prev = pendingToolArgsByCallId.get(callId)
        if (
          prev
          && prev.assistantId === assistantId
          && prev.toolName === toolName
        ) {
          if (delta) {
            prev.chunks.push(delta)
            prev.chars += delta.length
          }
          if (typeof linesWritten === 'number')
            prev.linesWritten = linesWritten
        }
        else if (prev) {
          flushPendingUi({ force: true })
          pendingToolArgsByCallId.set(callId, {
            assistantId,
            toolName,
            chunks: delta ? [delta] : [],
            chars: delta.length,
            ...(typeof linesWritten === 'number' ? { linesWritten } : null),
          })
        }
        else {
          pendingToolArgsByCallId.set(callId, {
            assistantId,
            toolName,
            chunks: delta ? [delta] : [],
            chars: delta.length,
            ...(typeof linesWritten === 'number' ? { linesWritten } : null),
          })
        }
        pendingEvents += 1
        streamStats.toolArgsChars += delta.length
      }
      await maybeFlushAndYieldUi()
      maybeLogStreamStats()
      continue
    }

    if (msg.role === 'user') {
      flushPendingUi({ force: true })
      const content = extractTextContent(msg.content)
      if (content) {
        insertStreamUserMessage(content, {
          splitAssistant: eventType === 'steer_injected',
          metadata: Object.keys(md).length > 0 ? { ...md } : undefined,
        })
      }
      await maybeFlushAndYieldUi({ force: true })
      maybeLogStreamStats()
      continue
    }

    if (msg.role === 'assistant') {
      const meta = (msg as { metadata?: StreamEventMetadata }).metadata
      const assistantReasoning
        = (
          msg as ACPMessage & { thinking?: ACPContent }
        ).reasoning_content
        ?? (msg as ACPMessage & { thinking?: ACPContent }).thinking
      const reasoning = extractTextContent(assistantReasoning)
      if (reasoning && !eventType) {
        flushPendingUi({ force: true })
        currentThinkingBlockOrdinal += 1
        currentThinkingPartId = createLocalThinkingPartId(
          assistantId,
          currentThinkingBlockOrdinal,
        )
        thinkingBlocks.set(currentThinkingPartId, reasoning)
        opts.startNewThinkingBlock(assistantId, currentThinkingPartId)
        opts.endThinkingBlock(
          assistantId,
          currentThinkingPartId,
          reasoning,
        )
        streamStats.thinkingChars += reasoning.length
        await maybeFlushAndYieldUi({ force: true })
      }
      const text = extractTextContent(msg.content)
      const trimmed = String(text ?? '').trim()
      const isControlNotice = (() => {
        if (!trimmed)
          return false
        const isApproval
          = eventType === 'tool_approval_requested'
            || Boolean(meta?.approval_requested)
        if (isApproval && /^tool approval requested:/i.test(trimmed))
          return true
        const isRequiresAction
          = eventType === 'requires_action' || Boolean(meta?.requires_action)
        if (isRequiresAction && /^action required:/i.test(trimmed))
          return true
        return false
      })()
      if (
        eventType === 'text_end'
        && text
        && !isControlNotice
        && !isDelegationRecordedFinalMessage(text)
      ) {
        trace('EVT', 'assistant_text_settled', {
          assistantId,
          chars: text.length,
        })
        replaceAssistantMarkdownWithSettledText(assistantId, text)
        streamStats.textChars += String(text).length
        await maybeFlushAndYieldUi({ force: true })
        maybeLogStreamStats()
        continue
      }
      if (text && !isControlNotice && !isDelegationRecordedFinalMessage(text)) {
        trace('EVT', 'assistant_text', { assistantId, chars: text.length })
        pendingAssistantMarkdownChunks.push(text)
        pendingAssistantMarkdownChars += text.length
        pendingEvents += 1
        // Coalesce markdown streaming into bounded chunks to prevent blocking renders.
        await maybeFlushAndYieldUi()
        streamStats.textChars += String(text).length
        maybeLogStreamStats()
      }

      const toolCalls = Array.isArray(
        (msg as { tool_calls?: unknown }).tool_calls,
      )
        ? ((msg as { tool_calls?: RawToolCall[] }).tool_calls ?? [])
        : []
      const hasText = Boolean(String(text ?? '').trim())
      // Tool calls should only be rendered when they come from an actual tool-call event.
      // Some upstream clients may mistakenly attach `tool_calls` to plain text deltas; in that case,
      // treat them as regular markdown to avoid misleading tool_call styling.
      // However, some streams encode tool calls as `text_delta` with empty content, so allow those.
      const shouldProcessToolCalls
        = toolCalls.length > 0
          && (!eventType
            || eventType === 'tool_call_end'
            || (eventType === 'text_delta' && !hasText))
      if (shouldProcessToolCalls) {
        // Preserve part ordering: tool_calls should appear after any preceding streamed markdown.
        flushPendingUi({ force: true })
        for (const tc of toolCalls) {
          const toolCallId = String(tc?.id ?? '').trim()
          if (toolCallId)
            toolCallIds.add(toolCallId)
          const toolName = normalizeToolName(tc?.function?.name)
          const thoughtSignature
            = typeof tc?.function?.thoughtSignature === 'string'
              ? tc.function.thoughtSignature
              : undefined
          const rawArgs = tc?.function?.arguments
          if (!toolCallId) {
            debugLog('tool_call_missing_id', {
              session_id: String(opts.state.sessionId ?? ''),
              tool_name: toolName,
              event_type: eventType,
            })
            continue
          }
          let args: Record<string, unknown> = {}
          if (
            rawArgs
            && typeof rawArgs === 'object'
            && !Array.isArray(rawArgs)
          ) {
            args = rawArgs as Record<string, unknown>
          }
          else if (typeof rawArgs === 'string') {
            args = withPerfSpan(
              'JSON.parse.tool_args',
              `chars=${rawArgs.length}`,
              () => {
                try {
                  const parsed = JSON.parse(rawArgs)
                  return parsed
                    && typeof parsed === 'object'
                    && !Array.isArray(parsed)
                    ? parsed
                    : { _raw: rawArgs }
                }
                catch {
                  return { _raw: rawArgs }
                }
              },
            )
          }

          // Track Explore calls so subagent events can be routed under the right tool header.
          if (toolCallId && String(toolName ?? '').toLowerCase() === 'task')
            subagentRouter.trackExploreCall(toolCallId, args)
          opts.upsertToolCallEnd(
            assistantId,
            String(tc.id ?? ''),
            toolName,
            args,
            thoughtSignature,
          )
        }
        await maybeFlushAndYieldUi({ force: true })
        maybeLogStreamStats()
      }

      if (meta?.approval_requested && meta?.tool_call_id && meta?.tool_name) {
        const toolCallId = String(meta.tool_call_id)
        const toolName = String(meta.tool_name)
        const riskLevel
          = typeof meta?.risk_level === 'string' ? meta.risk_level : undefined
        const rawApprovalArgs
          = meta?.approval_args ?? meta?.approvalArgs ?? meta?.args
        let approvalArgs: Record<string, unknown> | undefined
        if (
          rawApprovalArgs
          && typeof rawApprovalArgs === 'object'
          && !Array.isArray(rawApprovalArgs)
        ) {
          approvalArgs = rawApprovalArgs as Record<string, unknown>
        }
        else if (
          typeof rawApprovalArgs === 'string'
          && rawApprovalArgs.trim()
        ) {
          approvalArgs = withPerfSpan(
            'JSON.parse.approval_args',
            `chars=${rawApprovalArgs.length}`,
            () => {
              try {
                const parsed = JSON.parse(rawApprovalArgs)
                if (
                  parsed
                  && typeof parsed === 'object'
                  && !Array.isArray(parsed)
                ) {
                  return parsed as Record<string, unknown>
                }
                return { _raw: rawApprovalArgs }
              }
              catch {
                return { _raw: rawApprovalArgs }
              }
            },
          )
        }
        const hasApprovalArgs = Boolean(
          approvalArgs && Object.keys(approvalArgs).length > 0,
        )
        const transcriptArgs
          = (hasApprovalArgs ? approvalArgs : null)
            ?? toolCallLookup.findArgs(toolCallId)
            ?? approvalArgs
            ?? undefined
        const riskLabel = String(riskLevel ?? t('chatStatus.riskFallback'))
        const permission: ToolPermission
          = riskLevel === 'critical'
            ? 'require_action'
            : riskLevel === 'high'
              ? 'require_action'
              : 'pass'
        const req: ApprovalRequest = {
          id: opts.id('approve'),
          toolCallId,
          tool: toolName,
          permission,
          reason: buildApprovalReason('', toolName, transcriptArgs),
          ...(transcriptArgs ? { args: transcriptArgs } : {}),
          source: 'goatchain',
          riskLevel,
          status: 'pending',
        }
        const found = toolCallLookup.findCallInfo(toolCallId)
        const targetAssistantId = found?.assistantId ?? assistantId
        const isDuplicate
          = opts.state.approval.active?.toolCallId === toolCallId
            || opts.state.approval.queue.some(
              pending => pending.request.toolCallId === toolCallId,
            )
        if (!isDuplicate) {
          if (
            !opts.state.approval.active
            && opts.state.approval.queue.length === 0
          ) {
            opts.state.approval.decisions = {}
          }
          opts.insertApprovalPart(targetAssistantId, req)
          if (!opts.state.approval.active) {
            opts.state.approval.active = req
            opts.state.approval.pendingAutoRun = {
              assistantId: targetAssistantId,
              toolCallId,
            }
            opts.setAssistantStatus(
              assistantId,
              shouldAutoApproveRequest(opts.state.approval.mode, req)
                ? t('chatStatus.autoApprovingWithRisk', { risk: riskLabel })
                : t('chatStatus.awaitingApprovalWithRisk', { risk: riskLabel }),
            )
          }
          else {
            opts.state.approval.queue.push({
              request: req,
              assistantId: targetAssistantId,
            })
          }
        }
        debugLog('approval_requested', {
          session_id: String(opts.state.sessionId ?? ''),
          tool_call_id: toolCallId,
          tool_name: toolName,
          risk_level: riskLevel,
        })
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
      }

      if (meta?.requires_action) {
        const pausedKind =
          String(meta?.kind ?? '') === 'ask_user'
            ? 'ask_user'
            : 'tool_approval'
        const pausedQuestions = pausedKind === 'ask_user'
          ? normalizeAskUserQuestions(meta?.questions)
          : []
        opts.state.paused = {
          kind: pausedKind,
          toolCallId: meta?.tool_call_id ?? meta?.toolCallId,
          ...(pausedQuestions.length > 0
            ? { questions: pausedQuestions }
            : {}),
          actionId: meta?.actionId ?? meta?.action_id,
          checkpoint: meta?.checkpoint,
          checkpointRef: meta?.checkpointRef ?? (meta as any)?.checkpoint_ref,
          error: meta?.error,
        }
        opts.setAssistantStatus(
          assistantId,
          opts.state.paused.kind === 'ask_user'
            ? t('chatStatus.awaitingAnswers')
            : t('chatStatus.awaitingApproval'),
        )
        debugLog('requires_action', {
          session_id: String(opts.state.sessionId ?? ''),
          kind: meta?.kind,
          tool_call_id: meta?.tool_call_id ?? meta?.toolCallId,
        })
        await maybeFlushAndYieldUi()
        maybeLogStreamStats()
      }
    }
    else if (msg.role === 'tool') {
      flushPendingUi({ force: true })
      const output = extractTextContent(msg.content)
      const toolCallId = String(msg.tool_call_id ?? '')
      if (toolCallId)
        toolCallIds.add(toolCallId)
      const toolMeta = (msg as { metadata?: StreamEventMetadata }).metadata
      const isError = toolMeta?.error === true
      const structuredContentRaw = toolMeta?.structuredContent
      const structuredContent
        = structuredContentRaw
          && typeof structuredContentRaw === 'object'
          && !Array.isArray(structuredContentRaw)
          ? (structuredContentRaw as Record<string, unknown>)
          : undefined
      const baseResult: ToolResult = {
        id: toolCallId || opts.id('tool'),
        output,
        status: isError ? 'error' : 'success',
        structuredContent,
      }
      const found = toolCallId ? toolCallLookup.findCallInfo(toolCallId) : null
      const targetAssistantId = found?.assistantId ?? assistantId
      const toolNameLower = String(found?.toolName ?? '')
        .trim()
        .toLowerCase()

      // Bind Task tool results to subagent routing so any buffered subagent output can be nested correctly.
      if (toolCallId && toolNameLower === 'task')
        subagentRouter.bindTaskResultSubagent(toolCallId, structuredContentRaw)

      const result: ToolResult
        = isReadLikeToolName(toolNameLower)
          ? (() => {
              const truncated = truncateReadToolOutput(
                String(baseResult.output ?? ''),
                { maxLines: 10, ellipsisLine: '...' },
              )
              return {
                ...baseResult,
                output: truncated.output,
                outputAnsi: undefined,
                structuredContent: baseResult.structuredContent,
              } satisfies ToolResult
            })()
          : baseResult
      opts.pushMessage({ id: opts.id('m'), role: 'tool', result })
      opts.attachToolResultPart(targetAssistantId, result)
      opts.scheduleToolResultHighlight(targetAssistantId, result)
      await maybeFlushAndYieldUi()
      maybeLogStreamStats()

      const toolName = toolCallId ? found?.toolName : null
      const structuredRecord = structuredContent ?? null
      const revert = normalizeFileRevert(
        structuredRecord?.revert as Record<string, unknown> | null | undefined,
      )
      if (revert && toolCallId)
        ensureFileRevertsMap(opts.state).set(toolCallId, revert)
      if (toolName === 'TodoWrite') {
        opts.upsertChecklistPartFromGoatChain(
          targetAssistantId,
          'todo',
          toolCallId,
          structuredContentRaw,
        )
      }
      else if (toolName === 'TodoPlan') {
        opts.upsertChecklistPartFromGoatChain(
          targetAssistantId,
          'plan',
          toolCallId,
          structuredContentRaw,
        )
      }
    }

    const doneMeta = (msg as { metadata?: StreamEventMetadata }).metadata
    if (doneMeta?.done) {
      flushPendingUi({ force: true })
      const isFirstDone = !doneReceivedAt
      if (isFirstDone) {
        // First done event - initialize drain tracking
        doneReceivedAt = Date.now()
        try {
          const dbg = opts.state.ui?.debug
          if (dbg && typeof dbg === 'object') {
            dbg.lastRoundDoneAtMs = doneReceivedAt
            dbg.lastRoundHadError = Boolean(doneMeta?.error)
          }
        }
        catch {
          // ignore
        }
      }
      if (doneMeta?.usage)
        opts.applyUsageFromSession(doneMeta.usage)
      endThinking()
      const tokensUsed = Math.max(
        0,
        Math.floor(Number(opts.state.usage?.total_tokens ?? 0)),
      )
      lastTokensUsed = tokensUsed
      const stopReason = String(doneMeta?.stopReason ?? '').trim()
      const shouldAppendMeta = stopReason !== 'approval_required'
      lastDoneModelId = String(doneMeta?.modelId ?? '').trim() || undefined
      if (shouldAppendMeta) {
        appendRoundLlmMeta({
          modelId: String(doneMeta?.modelId ?? '').trim() || undefined,
          allowToolOnly: true,
        })
      }
      if (isFirstDone) {
        debugLog('agent_round_done', {
          session_id: String(opts.state.sessionId ?? ''),
          stopReason: doneMeta?.stopReason,
          modelStopReason: doneMeta?.modelStopReason,
          error: Boolean(doneMeta?.error),
        })
        maybeLogStreamStats({ force: true })
      }
      if (doneMeta?.stopReason === 'approval_required' || opts.state.paused) {
        opts.setAssistantStatus(
          assistantId,
          opts.state.paused?.kind === 'ask_user'
            ? t('chatStatus.awaitingAnswers')
            : t('chatStatus.awaitingApproval'),
        )
      }
      else {
        if (doneMeta?.error)
          opts.ensureErrorVisibleInTranscript(assistantId, doneMeta.error)
        opts.setAssistantStatus(
          assistantId,
          doneMeta?.error ? t('chatStatus.error') : t('chatStatus.done'),
        )
      }

      // Check drain limits
      const drainElapsed = doneReceivedAt
        ? Date.now() - doneReceivedAt
        : Infinity
      doneDrainSteps += 1

      // Get event priority for intelligent filtering
      const currentPriority = getPostDoneEventPriority(eventType)

      // Continue draining only if:
      // 1. Within step limit AND
      // 2. Within time limit AND
      // 3. Event is relevant (has priority) or this is first done
      const shouldContinueDrain
        = doneDrainSteps <= DONE_DRAIN_MAX_STEPS
          && drainElapsed <= DONE_DRAIN_MAX_DURATION_MS
          && (currentPriority > 0 || doneDrainSteps === 1)

      if (!shouldContinueDrain) {
        // Log why we're stopping the drain
        if (doneDrainSteps > DONE_DRAIN_MAX_STEPS) {
          debugLog('done_drain_step_limit', {
            session_id: String(opts.state.sessionId ?? ''),
            steps: doneDrainSteps,
            last_event_type: eventType,
          })
        }
        else if (drainElapsed > DONE_DRAIN_MAX_DURATION_MS) {
          debugLog('done_drain_time_limit', {
            session_id: String(opts.state.sessionId ?? ''),
            duration_ms: drainElapsed,
            last_event_type: eventType,
          })
        }
        else if (currentPriority === 0 && doneDrainSteps > 1) {
          debugLog('done_drain_skip_low_priority', {
            session_id: String(opts.state.sessionId ?? ''),
            event_type: eventType,
            step: doneDrainSteps,
          })
        }
        break
      }

      continue
    }
  }

  // Ensure buffered UI deltas are applied before we finalize status and return.
  flushPendingUi({ force: true })

  if (!opts.state.paused) {
    const m = opts.state.messages.find(m => m.id === assistantId)
    const statusPart
      = m && m.role === 'assistant'
        ? m.parts.find(
            (p): p is Extract<AssistantContentPart, { type: 'status' }> =>
              p.type === 'status',
          )
        : null
    if (statusPart?.text === t('chatStatus.thinking')) {
      opts.setAssistantStatus(assistantId, t('chatStatus.done'))
    }

    const latest = opts.state.messages.find(m => m.id === assistantId)
    const latestStatusText
      = latest && latest.role === 'assistant'
        ? String(
            latest.parts.find(
              (p): p is Extract<AssistantContentPart, { type: 'status' }> =>
                p.type === 'status',
            )?.text ?? '',
          ).trim()
        : ''
    const canBackfillMeta
      = !latestStatusText
        || (isTerminalStatusText(latestStatusText)
          && !isInterruptedStatusText(latestStatusText))
    if (canBackfillMeta) {
      appendRoundLlmMeta({
        modelId: lastDoneModelId,
        // Finalize-time backfill is the terminal round boundary. Keep llm_meta
        // even for tool-only transcripts so usage/tool-count does not disappear
        // when hook-evaluation/info parts are disabled or absent.
        allowToolOnly: true,
      })
    }
  }

  // Normal completion - return success result
  const toolCalls = (() => {
    const m = opts.state.messages.find(m => m.id === assistantId)
    if (!m || m.role !== 'assistant')
      return []
    return m.parts.flatMap(p => (p.type === 'tool_call' ? [p.call] : []))
  })()

  return {
    assistantId,
    toolCalls,
    success: true,
  }
}
