import { AI_SENDER, USER_SENDER, WEB_SELECTED_TEXT_TAG } from "@/constants";
import { logInfo, logWarn } from "@/logger";
import { AgentMessageStore } from "@/agentMode/session/AgentMessageStore";
import { GLOBAL_SCOPE, type ProjectScopeId } from "@/agentMode/session/scope";
import {
  AgentChatMessage,
  AgentMessagePart,
  AgentPlanEntry,
  AgentQuestionAnswers,
  AgentTodoListEntry,
  AgentToolCallOutput,
  AskUserQuestionPrompt,
  BackendDescriptor,
  BackendId,
  BackendProcess,
  BackendState,
  CurrentPlan,
  ModelSelection,
  NewAgentChatMessage,
  PERMISSION_ALLOW_KINDS,
  PERMISSION_REJECT_KINDS,
  PermissionDecision,
  PermissionOptionKind,
  PermissionPrompt,
  PlanSummary,
  PromptContent,
  PromptInput,
  PromptOutput,
  SessionEvent,
  SessionId,
  SessionUsage,
  StopReason,
  ToolCallContent,
  ToolCallDelta,
  ToolCallSnapshot,
} from "@/agentMode/session/types";
import {
  isNoteSelectedTextContext,
  isWebSelectedTextContext,
  MessageContext,
} from "@/types/message";
import { err2String, formatDateTime } from "@/utils";
import { ensureMultiAgentEntitlement, showMultiAgentUpgradePrompt } from "@/plusUtils";
import type { App } from "obsidian";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveMcpServers } from "@/agentMode/session/mcpResolver";
import { deriveChatTitleFromMessages } from "@/agentMode/session/chatHistoryMerge";
import { getSettings } from "@/settings/model";
import { ContextProcessor } from "@/contextProcessor";
import type { ContextMaterializationResult } from "@/context/projectContextMaterializer";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
import type { FanoutRunInput } from "@/agentMode/session/fanout/FanoutOrchestrator";
import { isFanout } from "@/agentMode/session/fanout/answerers";
import {
  buildConversationHistoryBlock,
  buildPriorFanoutContextBlock,
  FANOUT_HISTORY_MAX_CHARS,
  FANOUT_READONLY_PREAMBLE,
  renderFanoutComposite,
  serializeFanoutComposite,
  type FanoutTurn,
  type PendingFanoutContext,
} from "@/agentMode/session/fanout/fanoutTypes";

/**
 * Seam the session calls to dispatch a multi-agent read-only QA turn. Supplied
 * by `AgentSessionManager`; omitted in tests and on the single-agent path.
 */
export type RunFanoutTurn = (input: FanoutRunInput) => Promise<FanoutTurn>;

/**
 * Prefix opencode uses for placeholder titles before its title-summarizer
 * agent runs. Treating these as "no title" prevents the tab from briefly
 * showing "New session - 2026-…" before the LLM-generated label arrives.
 * Exported so the manager's `listSessions` history sweep applies the same
 * "placeholder is not a real title" rule.
 */
export const DEFAULT_TITLE_PREFIX = "New session";
/**
 * Memory backstop for tool output kept in long-lived React state — NOT a
 * display cap. At ~256KB it sits far above any realistic command/search/file
 * result (the chat renders output collapsed in a scrollable box, so normal and
 * large outputs show in full), and only a runaway multi-hundred-KB blob is
 * trimmed to stop the message store from holding it for the whole session. The
 * agent's own context always receives the full output regardless.
 */
const MAX_TOOL_OUTPUT_TEXT_CHARS = 256_000;
// ACP prompt results can arrive before the last session/update frames. Require
// a quiet interval after a cancelled backing prompt settles before dispatching
// a follow-up on the same session.
const CANCELLED_TURN_QUIET_MS = 1_000;
// Shared sentinel so `getPendingToolPermissions()` returns a stable reference
// when nothing is pending — preserves React `useState` setter bail-out
// behavior on idle subscription ticks.
const EMPTY_PERMISSIONS: PermissionPrompt[] = [];
// Same idea for `getPendingAskUserQuestions()`.
const EMPTY_QUESTIONS: AskUserQuestionPrompt[] = [];
// Canonical "no answers" map. Resolving an in-flight question with this on
// cancel/dispose makes the bridge treat it as a user cancellation.
const EMPTY_ANSWERS: AgentQuestionAnswers = Object.freeze({});
// Canonical "no fan-out" selection — referential stability on the single-agent path.
const EMPTY_BACKEND_IDS: ReadonlyArray<BackendId> = Object.freeze([]);
// Shared "no extra roots" array so a session created without project context
// keeps a stable reference (no fresh `[]` allocation per construction).
const EMPTY_ADDITIONAL_DIRECTORIES: string[] = Object.freeze([]) as unknown as string[];

/**
 * Optimistically swap `state.model.current.baseModelId` for the persisted
 * user selection so the picker's first paint matches what the user picked.
 *
 * Only `baseModelId` is seeded — `effort` is left as the backend reported.
 * For descriptor-style backends (Claude) effort lives out-of-band and is
 * applied via `applyInitialSessionConfig` → `setConfigOption`; seeding it
 * here would make that step see a matching value and silently skip the
 * real config write. For wire-effort backends (opencode-style) the
 * subsequent `setModel` call carries the user's effort through the
 * encoded id, and the backend's response replaces this seed entirely.
 *
 * Returns the input reference unchanged when no seed is possible.
 */
function seedSelectionIntoState(
  state: BackendState | null,
  selection: ModelSelection | undefined
): BackendState | null {
  if (!state || !state.model || !selection) return state;
  const entry = state.model.availableModels.find((m) => m.baseModelId === selection.baseModelId);
  if (!entry) return state;
  if (state.model.current.baseModelId === selection.baseModelId) return state;
  return {
    ...state,
    model: {
      ...state.model,
      current: { ...state.model.current, baseModelId: selection.baseModelId },
    },
  };
}

export type AgentSessionStatus =
  | "starting"
  | "idle"
  | "running"
  | "awaiting_permission"
  | "error"
  | "closed";

/**
 * Statuses that demand the user's eye when reached from `running` on a
 * backgrounded tab. The manager uses this to decide when to flag
 * `needsAttention`. Co-located with the union so adding a new status forces
 * a deliberate decision about whether it belongs here.
 */
export const ATTENTION_TRIGGER_STATUSES: ReadonlySet<AgentSessionStatus> = new Set([
  "idle",
  "error",
  "awaiting_permission",
]);

export interface AgentSessionListener {
  onMessagesChanged(): void;
  onStatusChanged(status: AgentSessionStatus): void;
  /**
   * Optional: fired when model, configOption, or mode state changes. The
   * picker treats all three as one notification channel — they all cause it
   * to rebuild and there's no value in fanning out separate callbacks.
   */
  onModelChanged?(): void;
  /** Optional: fired when the user-visible label changes. */
  onLabelChanged?(): void;
  /**
   * Optional: fired when the singleton `currentPlan` changes (created,
   * revised in place, or cleared). The floating plan card / preview tab
   * subscribe to this channel.
   */
  onCurrentPlanChanged?(): void;
  /**
   * Optional: fired when the live execution todo list changes (a backend
   * `plan` update with different content arrived, or the session reset).
   * The project-info Progress section subscribes to this channel.
   */
  onCurrentTodoListChanged?(): void;
  /**
   * Optional: fired when the "needs attention" flag flips. The tab strip
   * subscribes to render an accent dot on the brand icon for backgrounded
   * sessions that finished, errored, or paused for permission while the
   * user was looking at a different tab.
   */
  onNeedsAttentionChanged?(needsAttention: boolean): void;
}

/**
 * A pending project-context-updates note for a session: the coarse "sources may
 * have changed" nudge (`block`) plus the content `epoch` it covers. The epoch is
 * echoed back via {@link ProjectContextUpdatesHooks.markProjectContextUpdatesDelivered}
 * once the turn is accepted, so the manager can advance the session's cursor.
 */
export interface ProjectContextUpdates {
  epoch: number;
  block: string;
}

/**
 * Optional per-turn freshness hooks the manager wires into a PROJECT session (a
 * conditional spread leaves them absent for GLOBAL / context-free sessions, so
 * their turn path stays byte-identical to before this feature).
 */
export interface ProjectContextUpdatesHooks {
  /**
   * Sync accessor for a pending updates note for THIS session, or null when the
   * session has already seen the latest content epoch. Called each turn just
   * before the prompt is assembled (the manager flushes pending vault events
   * inside it, so a change made moments before send is seen).
   */
  getProjectContextUpdates?: () => ProjectContextUpdates | null;
  /**
   * Acknowledge that the note for `epoch` rode an ACCEPTED turn so it isn't
   * re-injected next turn. Called only after the backend accepts the prompt
   * (mirrors `firstPromptSent`); the fan-out path never calls it.
   */
  markProjectContextUpdatesDelivered?: (epoch: number) => void;
}

export interface AgentSessionStartOptions extends ProjectContextUpdatesHooks {
  backend: BackendProcess;
  cwd: string;
  internalId: string;
  backendId: BackendId;
  /**
   * Scope this session belongs to. Immutable like `backendId`; defaults to
   * {@link GLOBAL_SCOPE} (the implicit global workspace).
   */
  projectId?: ProjectScopeId;
  /**
   * Persisted user preference to apply after the backend's initial session
   * state. The session seeds it optimistically so the first picker paint
   * shows the user's pick, then confirms with the backend via setModel
   * (and, for descriptor-style backends, setConfigOption — handled by the
   * manager's `applyInitialSessionConfig` hook).
   */
  defaultModelSelection?: ModelSelection;
  /**
   * Snapshot from the preloader cache used to seed `currentState`
   * synchronously so the picker doesn't flash the previous session's
   * selection during the `backend.newSession` round-trip. Replaced by the
   * agent's fresh `newSession` response inside `initialize()`.
   */
  initialCachedState?: BackendState | null;
  /**
   * Optional descriptor accessor. The session uses it to resolve mode mappings
   * without coupling to specific backends. Manager-supplied; tests omit it.
   */
  getDescriptor?: () => BackendDescriptor | undefined;
  /**
   * Optional fan-out dispatcher. When supplied, a turn with more than one agent
   * runs the multi-agent read-only QA path instead of `backend.prompt()`.
   * Manager-supplied; tests and the single-agent path omit it.
   */
  runFanoutTurn?: RunFanoutTurn;
  /**
   * Resolve any `BackendId` to its display name for the persisted composite
   * headings. Manager-supplied; without it the serializer falls back to the id.
   */
  getDisplayName?: (backendId: BackendId) => string;
  /**
   * Resolve the Obsidian `App` for send-boundary side effects (the entitlement
   * re-check passes it to `validateLicenseKey`). Threaded via DI so the session
   * never reaches for the global `app`. Manager-supplied; tests omit it.
   */
  getApp?: () => App;
  /**
   * Resolves to the project's context-materialization result. Supplied by the
   * manager and awaited in `initialize` right BEFORE `newSession`, so the
   * session appears immediately (send-gated by the loading card) while prefetch
   * runs in the background and the backend still gets the roots once they're
   * ready. Absent for GLOBAL / context-free sessions — `initialize` then opens
   * without delay.
   *
   * Carries both the extra searchable roots (forwarded to `newSession`) and the
   * optional inline `<project_context>` block, captured for injection into this
   * session's first user prompt.
   */
  contextReady?: Promise<ContextMaterializationResult>;
}

/**
 * Pre-resolved state used by tests and by `loadSessionFromHistory` to
 * construct a session that bypasses the async backend startup.
 */
export interface AgentSessionStateOptions extends ProjectContextUpdatesHooks {
  backend: BackendProcess;
  backendSessionId: SessionId;
  internalId: string;
  backendId: BackendId;
  /** Scope this session belongs to. See {@link AgentSessionStartOptions.projectId}. */
  projectId?: ProjectScopeId;
  initialState?: BackendState | null;
  /**
   * Optional persisted user preference applied to the warm/adopted session.
   * Same role as `AgentSessionStartOptions.defaultModelSelection` — seeded
   * optimistically on construction; setModel runs async and reverts the
   * seed on failure.
   */
  defaultModelSelection?: ModelSelection;
  cwd?: string | null;
  getDescriptor?: () => BackendDescriptor | undefined;
  runFanoutTurn?: RunFanoutTurn;
  getDisplayName?: (backendId: BackendId) => string;
  getApp?: () => App;
}

/**
 * Per-chat Agent Mode session. Owns its `AgentMessageStore`, the lifecycle
 * of one backend session id, and the `AbortController` that cancels in-flight
 * turns.
 *
 * Construction is split: `AgentSession.start()` returns synchronously with
 * status `"starting"` so the UI can swap to the new (empty) chat immediately.
 * The backend `newSession` runs in the background; once it resolves the
 * session transitions to `"idle"` and `sendPrompt` becomes usable. While
 * starting, `sendPrompt` throws — the chat UI gates the send button on
 * `getStatus() === "starting"`.
 */
export class AgentSession {
  readonly store = new AgentMessageStore();
  readonly internalId: string;
  readonly backendId: BackendId;
  /** Immutable scope binding ({@link GLOBAL_SCOPE} or a project id). */
  readonly projectId: ProjectScopeId;
  /** Resolves when `newSession` succeeds; rejects when it fails. */
  readonly ready: Promise<void>;
  private backendSessionId: SessionId | null = null;
  private readonly backend: BackendProcess;
  private readonly cwd: string | null;
  // Resolves to the project's context-materialization result; awaited before
  // `newSession` (null for context-free / resumed sessions, which open without
  // delay).
  private readonly contextReady: Promise<ContextMaterializationResult> | null;
  // The project's `<project_context>` block, captured from `contextReady` in
  // `initialize`. Inlined into the FIRST user prompt only (see `runTurn` /
  // `buildPromptBlocks`); null for GLOBAL / context-free / resumed sessions.
  private projectContextBlock: string | null = null;
  // Flips true once the first user prompt has been built, so the project-context
  // block is injected exactly once at the head of the conversation.
  private firstPromptSent = false;
  private readonly getDescriptor: (() => BackendDescriptor | undefined) | null;
  private readonly runFanoutTurn: RunFanoutTurn | null;
  private readonly getDisplayName: ((backendId: BackendId) => string) | null;
  private readonly getApp: (() => App) | null;
  // Per-turn project-context freshness hooks (see `ProjectContextUpdatesHooks`).
  // Null for GLOBAL / context-free sessions, so their turn path is unchanged.
  private readonly getProjectContextUpdatesFn: (() => ProjectContextUpdates | null) | null;
  private readonly markProjectContextUpdatesDeliveredFn: ((epoch: number) => void) | null;
  // `status` is derived from the primitives below — see `getStatus()`.
  // `cachedStatus` is a memo of the last value we fired through
  // `onStatusChanged`, used purely for change detection. It is not the
  // source of truth; never read it from anywhere except
  // `recomputeStatusIfChanged()`.
  private cachedStatus: AgentSessionStatus = "starting";
  // Set in `initialize`'s catch when the backend's `newSession` rejects.
  // Combined with `backendSessionId === null` this yields the startup
  // `"error"` status.
  private startupFailed = false;
  // Set in `runTurn`'s catch; cleared at the top of `sendPrompt` once
  // preconditions pass. Yields the per-turn `"error"` status while the
  // session sits idle between a failed turn and the next prompt.
  private lastTurnError = false;
  // The resolved answerer selection for the most recent turn (deduped
  // `@`-mentioned installed agents). Empty on the single-agent path.
  private lastMentionedAgents: ReadonlyArray<BackendId> = EMPTY_BACKEND_IDS;
  // Fan-out turns the visible backend never processed. The next single-agent turn
  // injects them in order as a labeled prior-turn block, then clears, so follow-ups
  // keep the QA context. LIVE-ONLY — never persisted.
  private pendingFanoutContext: PendingFanoutContext[] = [];
  private placeholderId: string | null = null;
  // A task update routed to an earlier turn still counts as activity consumed
  // by the current backend prompt, even though it adds nothing to this turn's
  // placeholder.
  private currentTurnHadRoutedToolActivity = false;
  // ACP `messageId`s seen on this turn's content chunks. Used to re-route
  // trailing chunks that a backend flushes *after* the `session/prompt` result
  // (observed with opencode + fast DeepSeek models) to the right message.
  private currentMessageIds = new Set<string>();
  // The most-recently-completed turn's placeholder plus the `messageId`s it
  // owned. A grace window: late content chunks naming one of these still land
  // on the finished message instead of being dropped — or, worse, appended to
  // a newer turn's placeholder. Replaced on the next completion, cleared on
  // cancel/dispose.
  private settledStream: { placeholderId: string; messageIds: Set<string> } | null = null;
  // A locally-cancelled prompt whose backend promise has not settled yet. A
  // follow-up may be composed immediately, but its backend prompt waits on this
  // barrier so output from the cancelled generation cannot target the new turn.
  private cancelledPromptDrain: Promise<void> | null = null;
  private cancelledTurnActivity = 0;
  private cancelledMessageIds = new Set<string>();
  private abortController: AbortController | null = null;
  private listeners = new Set<AgentSessionListener>();
  private unregisterSessionHandler: (() => void) | null = null;
  /**
   * Cached normalized state — produced by the backend at session start /
   * resume / load and refreshed via `state_changed` events or per-dimension
   * `setSession*` responses.
   */
  private currentState: BackendState | null = null;
  private label: string | null = null;
  // Tracks who set the current label so an agent-pushed `session_info_update`
  // can't clobber a label the user explicitly chose via Rename.
  private labelSource: "user" | "agent" | null = null;
  private disposed = false;
  // Pending permission resolvers keyed by toolCallId. Populated when an
  // ExitPlanMode permission request arrives (via the wrapped prompter); the
  // chat card resolves them through `resolvePlanProposalPermission`.
  private pendingPlanResolvers = new Map<
    string,
    {
      request: PermissionPrompt;
      resolve: (resp: PermissionDecision) => void;
    }
  >();
  // Pending permission resolvers for general (non-plan) tool calls. Populated
  // by `handleToolPermission` when the wrapped prompter routes a request to
  // this session; the inline `ToolPermissionCard` resolves them through
  // `resolveToolPermission`.
  private pendingToolResolvers = new Map<
    string,
    {
      request: PermissionPrompt;
      resolve: (resp: PermissionDecision) => void;
    }
  >();
  // Pending AskUserQuestion resolvers keyed by requestId. Populated by
  // `handleAskUserQuestion` when the wrapped ask-question prompter routes a
  // request to this session; the inline `AskUserQuestionCard` resolves them
  // through `resolveAskUserQuestion`.
  private pendingQuestionResolvers = new Map<
    string,
    {
      request: AskUserQuestionPrompt;
      resolve: (answers: AgentQuestionAnswers) => void;
    }
  >();
  // Singleton "current plan" for the floating card. At most one per session
  // while in canonical plan mode and a plan has been proposed; cleared on a
  // terminal user decision or when the canonical mode flips out of plan.
  private currentPlan: CurrentPlan | null = null;
  // Live execution todo list — the latest `plan` update's entries, normalized
  // for consumers (the trail's PlanPill reads the message part instead; this
  // snapshot feeds surfaces outside the message flow). LIVE-ONLY by design:
  // chat persistence drops plan parts, so a resumed/reloaded session starts
  // at null and repopulates on the agent's next todo update.
  private currentTodoList: AgentTodoListEntry[] | null = null;
  // Signature of the last applied list — multiple equal plan updates (e.g.
  // opencode's synthesized + occasional real plan channel) must not re-notify.
  private currentTodoListSignature: string | null = null;
  // Latest backend-agnostic token-usage snapshot, or null until the first
  // `usage_update` (or a persisted snapshot seeded on resume). Session-scoped:
  // not tied to any turn placeholder.
  private currentUsage: SessionUsage | null = null;
  // Monotonic counter for `currentPlan.id` so the React tree can detect a
  // *new* plan-mode review (vs. an in-place revision that bumps `revision`).
  private planSeq = 0;
  // Tool-call ids the user has already finalized a decision on.
  private decidedPlanToolCallIds = new Set<string>();
  // True when something happened (turn ended, error, permission prompt) while
  // this session was not the active tab. The manager owns the policy for
  // setting this; the session just exposes the flag and notification channel.
  private needsAttention = false;
  // Streaming backends emit `agent_message_chunk`/`agent_thought_chunk` and
  // (for codex) `tool_call_update` at up to ~160 fps. We update the store
  // immediately but coalesce the React-facing `onMessagesChanged` callback
  // to one fire per animation frame so the trail doesn't rerender 200×/sec.
  // Non-streaming callers use `notifyMessages()` directly for immediate
  // flush on turn end / error / permission prompts.
  private notifyScheduled = false;
  private notifyHandle: ReturnType<typeof setTimeout> | number | null = null;

  /** Prefer `AgentSession.start(...)` in production so backend startup runs async. */
  constructor(opts: AgentSessionStateOptions | AgentSessionStartOptions) {
    this.backend = opts.backend;
    this.internalId = opts.internalId;
    this.backendId = opts.backendId;
    this.projectId = opts.projectId ?? GLOBAL_SCOPE;
    this.cwd = opts.cwd ?? null;
    this.getDescriptor = opts.getDescriptor ?? null;
    this.runFanoutTurn = opts.runFanoutTurn ?? null;
    this.getDisplayName = opts.getDisplayName ?? null;
    this.getApp = opts.getApp ?? null;
    this.getProjectContextUpdatesFn = opts.getProjectContextUpdates ?? null;
    this.markProjectContextUpdatesDeliveredFn = opts.markProjectContextUpdatesDelivered ?? null;
    // Only the start path (newSession) awaits context roots; adopted/resumed
    // sessions had theirs forwarded by the manager's resume/load call already.
    this.contextReady = "contextReady" in opts ? (opts.contextReady ?? null) : null;
    if ("backendSessionId" in opts) {
      this.backendSessionId = opts.backendSessionId;
      const originalState = opts.initialState ?? null;
      this.currentState = seedSelectionIntoState(originalState, opts.defaultModelSelection);
      this.unregisterSessionHandler = this.backend.registerSessionHandler(
        opts.backendSessionId,
        (event) => this.handleSessionEvent(event)
      );
      // Sync the status cache so the first recomputeStatusIfChanged doesn't
      // fire a spurious "starting → idle" transition that no one observed.
      this.cachedStatus = this.getStatus();
      // Gate `ready` on the model confirmation round-trip so `sendPrompt`
      // can't fire on the probe's model before the user's persisted
      // selection is applied to the backend.
      this.ready =
        opts.defaultModelSelection && originalState
          ? this.confirmSeededSelection(opts.defaultModelSelection, originalState)
          : Promise.resolve();
    } else {
      // Eagerly seed from the preloader cache so the picker doesn't fall
      // back to the prior session's `current` while `backend.newSession` is
      // in flight. `initialize()` replaces this with the agent's response.
      this.currentState = seedSelectionIntoState(
        opts.initialCachedState ?? null,
        opts.defaultModelSelection
      );
      this.ready = this.initialize(opts);
    }
  }

  /**
   * Construct an `AgentSession` synchronously and kick off backend
   * initialization in the background. The returned session is immediately
   * registerable with the manager and renderable in the UI; `sendPrompt`
   * is gated until `ready` resolves.
   */
  static start(opts: AgentSessionStartOptions): AgentSession {
    return new AgentSession(opts);
  }

  /** The backend session id, or null while still starting. */
  getBackendSessionId(): SessionId | null {
    return this.backendSessionId;
  }

  private async initialize(opts: AgentSessionStartOptions): Promise<void> {
    const { backend, cwd, defaultModelSelection } = opts;
    try {
      // Await the project's context roots (if any) BEFORE opening the session.
      // The session is already visible and send-gated by the loading card; this
      // delay only postpones the backend round-trip until prefetch settles. The
      // promise never rejects (degrades to empty), so it can't strand startup.
      const contextResult = this.contextReady ? await this.contextReady : null;
      const additionalDirectories =
        contextResult?.additionalDirectories ?? EMPTY_ADDITIONAL_DIRECTORIES;
      // Capture the inline `<project_context>` block for this session's first
      // prompt. The roots go to `newSession` below; the block rides the first
      // user message (see `runTurn`).
      this.projectContextBlock = contextResult?.projectContextBlock ?? null;
      if (this.disposed) return;
      const resp = await backend.newSession({
        cwd,
        mcpServers: resolveMcpServers(backend, getSettings().agentMode?.mcpServers),
        // Capture the owning scope alongside cwd so the backend can resolve
        // this project's instructions; GLOBAL_SCOPE for the global workspace.
        projectId: this.projectId,
        // Extra searchable roots from the project's materialized context. The
        // backend honors them only when it advertises the capability.
        additionalDirectories,
      });
      if (this.disposed) return;
      const modelLog = resp.state.model
        ? `model=${resp.state.model.current.baseModelId} (available: ${resp.state.model.availableModels
            .map((m) => m.baseModelId)
            .join(", ")})`
        : "agent did not report model state";
      logInfo(`[AgentMode] session ${resp.sessionId} ${modelLog}`);
      this.backendSessionId = resp.sessionId;
      this.currentState = seedSelectionIntoState(resp.state, defaultModelSelection);
      this.unregisterSessionHandler = this.backend.registerSessionHandler(resp.sessionId, (event) =>
        this.handleSessionEvent(event)
      );
      // dispose() may have run between newSession resolving and now.
      if (this.disposed) {
        this.unregisterSessionHandler();
        this.unregisterSessionHandler = null;
        return;
      }
      this.recomputeStatusIfChanged();
      this.notifyModelChanged();

      if (defaultModelSelection) {
        await this.confirmSeededSelection(defaultModelSelection, resp.state);
      }
    } catch (err) {
      if (this.disposed) return;
      logWarn(`[AgentMode] session/new failed for ${this.internalId}`, err);
      this.startupFailed = true;
      this.recomputeStatusIfChanged();
      throw err instanceof Error ? err : new Error(err2String(err));
    }
  }

  /**
   * Latest known unified picker state for this session — model catalog,
   * canonical mode, canonical effort. `null` while the session is still
   * starting and the agent hasn't reported anything yet.
   */
  getState(): BackendState | null {
    return this.currentState;
  }

  /**
   * Switch the active model on this session. On success, replaces the
   * cached `BackendState` with the freshly-translated one returned by the
   * backend and notifies `onModelChanged` listeners.
   *
   * Throws `MethodUnsupportedError` if the backend does not support model
   * switching. Callers should treat that as "model switching is not
   * available" and degrade the UI accordingly.
   */
  async setModel(modelId: string): Promise<void> {
    if (this.getStatus() === "closed") throw new Error("Session is closed");
    if (!this.backendSessionId) throw new Error("Session is still starting");
    const next = await this.backend.setSessionModel({
      sessionId: this.backendSessionId,
      modelId,
    });
    this.currentState = next;
    this.notifyModelChanged();
  }

  /**
   * Apply an encoded model wire id through whichever channel the current model
   * state declares. Backends whose catalog comes from a `category:"model"`
   * config option (opencode ≥ 1.15.13) switch via `session/set_config_option`;
   * everyone else via `session/set_model`. Falls back to `setModel` before any
   * model state is known. The encoded wire id is identical for both channels.
   */
  async applyModelWireId(wireId: string): Promise<void> {
    const apply = this.currentState?.model?.apply;
    if (apply?.kind === "setConfigOption") {
      await this.setConfigOption(apply.configId, wireId);
      return;
    }
    await this.setModel(wireId);
  }

  /**
   * Apply the persisted user selection to the backend via `setModel`.
   * Runs whenever `defaultModelSelection` is supplied — `setModel` is
   * idempotent, and the wire-encoding short-circuit below makes it a
   * pure no-op when the selection already matches the backend's report.
   *
   * On success the backend's response replaces `currentState`. On
   * failure any optimistic baseModelId seed is reverted to `originalState`
   * so the picker drops back to whatever the backend actually has.
   *
   * Skips the round-trip when the encoded form matches. Dispatches through
   * `descriptor.applySelection` rather than a raw `applyModelWireId`, so a
   * cross-backend seed carrying a drafted effort is applied through the
   * backend's own channel — notably config-option opencode (≥1.15.13), where
   * effort is a separate `thought_level` option that must be sent after the
   * bare model, not packed into the model wire id. opencode has no
   * `applyInitialSessionConfig` to split it post-startup, so the seed must be
   * correct here.
   */
  private async confirmSeededSelection(
    selection: ModelSelection,
    originalState: BackendState
  ): Promise<void> {
    const descriptor = this.getDescriptor?.();
    if (!descriptor) return;
    const encoded = descriptor.wire.encode(selection);
    const originalEncoded = originalState.model
      ? descriptor.wire.encode(originalState.model.current)
      : null;
    const originalEffort = originalState.model?.current.effort ?? null;
    if (encoded === originalEncoded && selection.effort === originalEffort) return;
    // Config-option backends (opencode ≥1.15.13) guard their model switch on
    // the *current* state, and the optimistic baseModelId seed already shows
    // the target — which would skip the real switch and strand the backend on
    // its originally-reported model. Drop the seed first so `applySelection`
    // sees the true state and issues the switch. setModel-style backends always
    // issue the round-trip regardless of current, so their optimistic seed can
    // stand and the picker doesn't blink.
    const configOptionBacked = originalState.model?.apply?.kind === "setConfigOption";
    if (configOptionBacked) {
      this.currentState = originalState;
    }
    try {
      // Clearing effort to the agent default on a config-option backend whose
      // process baked a concrete effort: the base already matches, so
      // `applySelection` skips the model write and returns for null effort,
      // leaving the session on the stale concrete effort. Re-write the bare
      // model option to reset effort to the model's native default first.
      if (configOptionBacked && selection.effort === null && originalEffort !== null) {
        await this.applyModelWireId(descriptor.wire.encode(selection));
        return;
      }
      await descriptor.applySelection(this, selection);
    } catch (e) {
      logWarn(`[AgentMode] could not apply seeded selection ${encoded}; reverting seed`, e);
      this.currentState = originalState;
      this.notifyModelChanged();
    }
  }

  /**
   * Set a session configuration option (e.g. effort). Reuses
   * `notifyModelChanged` because the picker treats model and configOption
   * changes as one channel.
   */
  async setConfigOption(configId: string, value: string): Promise<void> {
    if (this.getStatus() === "closed") throw new Error("Session is closed");
    if (!this.backendSessionId) throw new Error("Session is still starting");
    const next = await this.backend.setSessionConfigOption({
      sessionId: this.backendSessionId,
      configId,
      value,
    });
    this.currentState = next;
    this.notifyModelChanged();
    this.clearCurrentPlanIfModeLeft();
  }

  /**
   * Switch the active session mode (claude permission mode, codex
   * sandbox preset, etc.). On success, replaces the cached state and
   * notifies `onModelChanged` listeners.
   *
   * Throws `MethodUnsupportedError` when the backend doesn't support mode
   * switching.
   */
  async setMode(modeId: string): Promise<void> {
    if (this.getStatus() === "closed") throw new Error("Session is closed");
    if (!this.backendSessionId) throw new Error("Session is still starting");
    const next = await this.backend.setSessionMode({
      sessionId: this.backendSessionId,
      modeId,
    });
    this.currentState = next;
    this.notifyModelChanged();
    this.clearCurrentPlanIfModeLeft();
  }

  /**
   * Whether the user can swap the active model on this session. Config-option-
   * backed catalogs (opencode ≥ 1.15.13) switch via `setConfigOption`; everyone
   * else via `setModel`.
   */
  canSwitchModel(): boolean | null {
    if (this.getStatus() === "starting") return false;
    return this.currentState?.model?.apply.kind === "setConfigOption"
      ? this.backend.isSetSessionConfigOptionSupported()
      : this.backend.isSetSessionModelSupported();
  }

  /**
   * Whether the user can swap effort. Descriptor-style backends (claude) and
   * config-option-backed models (opencode ≥ 1.15.13) route effort via
   * `setConfigOption`; suffix-style models that pack effort into the wire id
   * (codex, older opencode) via `setModel`. The wire routing is encapsulated
   * here — UI consumers ask intent only.
   */
  canSwitchEffort(): boolean | null {
    if (this.getStatus() === "starting") return false;
    const descriptor = this.getDescriptor?.();
    if (!descriptor) return null;
    if (descriptor.wire.effortConfigFor) return this.backend.isSetSessionConfigOptionSupported();
    return this.currentState?.model?.apply.kind === "setConfigOption"
      ? this.backend.isSetSessionConfigOptionSupported()
      : this.backend.isSetSessionModelSupported();
  }

  /**
   * Whether the user can swap modes. Each mode option carries its own
   * apply spec (`setMode` or `setConfigOption`); within a single backend
   * the dispatch path is consistent, so we sample the first option.
   */
  canSwitchMode(): boolean | null {
    if (this.getStatus() === "starting") return false;
    const mode = this.currentState?.mode;
    if (!mode) return null;
    const sample = mode.options[0];
    if (!sample) return null;
    const spec = mode.apply[sample.value];
    if (!spec) return null;
    return spec.kind === "setConfigOption"
      ? this.backend.isSetSessionConfigOptionSupported()
      : this.backend.isSetSessionModeSupported();
  }

  /**
   * The status is derived from underlying primitives so it cannot drift
   * from reality: any combination of `disposed`, `backendSessionId`,
   * resolver-map sizes, `abortController`, `startupFailed`, and
   * `lastTurnError` maps to exactly one status. Mutating any of those
   * primitives implicitly transitions the status; consumers observe the
   * change via `onStatusChanged` after `recomputeStatusIfChanged()` runs.
   */
  getStatus(): AgentSessionStatus {
    if (this.disposed) return "closed";
    if (this.backendSessionId === null) {
      return this.startupFailed ? "error" : "starting";
    }
    if (
      this.pendingPlanResolvers.size +
        this.pendingToolResolvers.size +
        this.pendingQuestionResolvers.size >
      0
    ) {
      return "awaiting_permission";
    }
    if (this.abortController !== null) return "running";
    if (this.lastTurnError) return "error";
    return "idle";
  }

  getNeedsAttention(): boolean {
    return this.needsAttention;
  }

  markNeedsAttention(): void {
    if (this.needsAttention) return;
    this.needsAttention = true;
    this.notifyNeedsAttentionChanged();
  }

  clearNeedsAttention(): void {
    if (!this.needsAttention) return;
    this.needsAttention = false;
    this.notifyNeedsAttentionChanged();
  }

  private notifyNeedsAttentionChanged(): void {
    for (const l of this.listeners) {
      try {
        l.onNeedsAttentionChanged?.(this.needsAttention);
      } catch (e) {
        logWarn(`[AgentMode] needs-attention listener threw`, e);
      }
    }
  }

  /**
   * Whether this session has at least one user-visible message. The model
   * picker uses this to decide whether non-active backend entries should be
   * hidden (mid-conversation) or shown (empty new tab).
   */
  hasUserVisibleMessages(): boolean {
    return this.store.getDisplayMessages().length > 0;
  }

  /**
   * Replace the display transcript from persisted history (a markdown note or
   * a backend's on-disk session store) and notify subscribers so an already-
   * open chat view re-renders immediately. `store.loadMessages` alone mutates
   * state without firing `onMessagesChanged`, so a freshly-activated tab would
   * otherwise render blank until the next backend-identity change (e.g. the
   * user switching tabs and back).
   */
  loadDisplayMessages(messages: AgentChatMessage[]): void {
    this.store.loadMessages(messages);
    this.notifyMessages();
  }

  /**
   * User-supplied label for this session (shown in the tab strip). `null`
   * means "no label" — the UI falls back to a positional default like
   * "Session N".
   */
  getLabel(): string | null {
    return this.label;
  }

  /**
   * Who set the current label: `"user"` (Rename), `"agent"`
   * (`session_info_update` / title poll), or `null` when unlabeled. The
   * session index records this so a user rename survives native
   * `listSessions` sweeps the same way it survives agent retitles here.
   */
  getLabelSource(): "user" | "agent" | null {
    return this.labelSource;
  }

  setLabel(label: string | null): void {
    const next = label?.trim() ? label.trim() : null;
    if (next === this.label) return;
    this.label = next;
    this.labelSource = next ? "user" : null;
    this.notifyLabelChanged();
  }

  /**
   * Apply a label pushed by the backend agent (via `session_info_update`).
   * No-op when the user has already renamed this session — Rename wins so
   * later agent-side title revisions don't blow away the user's choice.
   */
  /**
   * Apply a label restored from persisted history with its original source.
   * A user-renamed title is reapplied as a sticky user rename; an agent or
   * derived title is applied agent-sourced, so a resumed opencode/codex
   * session can still refresh its title from later `session_info_update` /
   * title-poll updates instead of being frozen as if the user had renamed it.
   */
  restoreLabel(label: string, source: "user" | "agent"): void {
    if (source === "user") this.setLabel(label);
    else this.applyAgentLabel(label);
  }

  /**
   * Whether this backend produces its own clean session titles (opencode). When
   * false (codex, Claude Code) the session derives the tab label client-side and
   * ignores any backend-provided title. Defaults to `true` when no descriptor is
   * wired (legacy/test construction), preserving the prior trust-the-backend
   * behavior.
   */
  private backendSummarizesTitle(): boolean {
    return this.getDescriptor?.()?.summarizesSessionTitle ?? true;
  }

  private applyAgentLabel(label: string | null | undefined): void {
    if (this.labelSource === "user") return;
    const next = label?.trim() ? label.trim() : null;
    if (next === this.label) return;
    this.label = next;
    this.labelSource = next ? "agent" : null;
    this.notifyLabelChanged();
  }

  private notifyLabelChanged(): void {
    for (const l of this.listeners) {
      try {
        l.onLabelChanged?.();
      } catch (e) {
        logWarn(`[AgentMode] label listener threw`, e);
      }
    }
  }

  subscribe(listener: AgentSessionListener): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  /**
   * Submit a user prompt. Synchronously appends the user message + an empty
   * assistant placeholder to the store and kicks off the backend prompt.
   * Streaming session events mutate the placeholder in place. Returns:
   *   - `userMessageId`: id of the appended user message.
   *   - `turn`: promise that resolves with `StopReason` when the turn
   *     completes, or rejects on transport errors.
   */
  sendPrompt(
    displayText: string,
    context?: MessageContext,
    promptContent?: PromptContent[],
    mentionedAgents?: ReadonlyArray<BackendId>
  ): { userMessageId: string; turn: Promise<StopReason> } {
    const status = this.getStatus();
    if (status === "starting") {
      throw new Error("Session is still starting");
    }
    if (status === "running" || status === "awaiting_permission") {
      throw new Error("Session already has a turn in flight");
    }
    if (status === "closed") {
      throw new Error("Session is closed");
    }

    const userMessage: NewAgentChatMessage = {
      message: displayText,
      sender: USER_SENDER,
      timestamp: formatDateTime(new Date()),
      isVisible: true,
      context,
      // Surface attached images in the posted bubble. The backend consumes the
      // original `promptContent` image blocks; this is a display-only projection.
      content: buildUserDisplayContent(displayText, promptContent),
    };
    const userMessageId = this.store.addMessage(userMessage);

    const placeholder: NewAgentChatMessage = {
      message: "",
      sender: AI_SENDER,
      timestamp: formatDateTime(new Date()),
      isVisible: true,
      parts: [],
    };
    this.placeholderId = this.store.addMessage(placeholder);
    this.currentMessageIds = new Set();
    this.currentTurnHadRoutedToolActivity = false;
    this.notifyMessages();

    // Backends without a title summarizer (codex, Claude Code) have no usable
    // backend-provided title, so derive the tab label from the first user
    // message. Recorded agent-sourced (not "user"), so a later Rename still wins.
    if (this.label === null && !this.backendSummarizesTitle()) {
      this.applyAgentLabel(deriveChatTitleFromMessages(this.store.getDisplayMessages()));
    }

    // Record the fan-out selection for this turn (empty = single-agent path).
    this.lastMentionedAgents =
      mentionedAgents && mentionedAgents.length > 0 ? mentionedAgents : EMPTY_BACKEND_IDS;

    this.abortController = new AbortController();
    // Clear any prior terminal error before the new turn starts so the
    // derived status reflects the fresh `"running"` state. Both flips
    // are recomputed together so listeners see one transition.
    this.lastTurnError = false;
    this.recomputeStatusIfChanged();

    const turn = this.runTurn(displayText, userMessageId, context, promptContent);
    return { userMessageId, turn };
  }

  /** The resolved answerer selection for the most recent `sendPrompt`; empty on the single-agent path. */
  getLastMentionedAgents(): ReadonlyArray<BackendId> {
    return this.lastMentionedAgents;
  }

  private async runTurn(
    displayText: string,
    userMessageId: string,
    context: MessageContext | undefined,
    promptContent?: PromptContent[]
  ): Promise<StopReason> {
    const placeholderId = this.placeholderId;
    const sessionId = this.backendSessionId!;
    const signal = this.abortController!.signal;
    try {
      const priorPromptDrain = this.cancelledPromptDrain;
      if (priorPromptDrain) {
        await Promise.race([
          priorPromptDrain,
          new Promise<void>((resolve) => {
            signal.addEventListener("abort", () => resolve(), { once: true });
          }),
        ]);
      }

      // Extract live Web Viewer content (reader-mode markdown, YouTube
      // transcripts) just before the prompt is built so it reflects the page
      // at send/flush time, not at compose time. Only take the async hop when
      // there are web tabs — otherwise `backend.prompt` must be invoked
      // synchronously within this turn (callers rely on that timing).
      const hasWebTabs = (context?.webTabs?.length ?? 0) > 0;
      const webTabBlock = hasWebTabs ? await serializeWebTabContext(context) : "";
      // The project-context block rides the FIRST user prompt only; capture it
      // up front so both the fan-out and single-agent paths can inject it.
      const isFirstTurn = !this.firstPromptSent;
      const projectContextBlock = isFirstTurn ? this.projectContextBlock : null;
      // The coarse "sources may have changed" note for an ongoing/resumed project
      // session. Read once here (the manager flushes pending vault events inside
      // the getter) so both paths inject the same value; acked only after the
      // single-agent backend accepts the turn. Null for GLOBAL / current sessions.
      const projectContextUpdates = this.getProjectContextUpdatesFn?.() ?? null;
      const projectContextUpdatesBlock = projectContextUpdates?.block ?? null;

      // Fan-out path: the `@`-mentioned answerers dispatch the identical prompt in
      // parallel ephemeral read-only sub-sessions and the main agent summarizes.
      // It never talks to the visible backend, so it doesn't inject/flush the
      // pending fan-out buffer (only appends on completion). `isFanout` collapses
      // the degenerate `[main]` case so the main agent never both answers and
      // summarizes — shared with the composer's `mentionedAgents` gate.
      if (
        this.runFanoutTurn &&
        isFanout(this.lastMentionedAgents, this.backendId) &&
        placeholderId
      ) {
        // Authoritative paywall: a fan-out turn is Plus-only, and the typeahead UI
        // gate can be bypassed (pasting a pill), so re-check entitlement here at
        // the session boundary. Paying users short-circuit; everyone else is hard-blocked.
        if (!(await this.ensureMultiAgentEntitlement())) {
          return this.blockFanoutForEntitlement(placeholderId);
        }

        // Give every fan-out agent the PRIOR visible transcript as a read-only
        // `<conversation_history>` block so follow-ups ("the answer above") work.
        // "Prior" excludes this turn's own user message + placeholder. Null → unchanged prompt.
        const historyBlock = buildConversationHistoryBlock(
          this.priorDisplayMessages(userMessageId, placeholderId),
          FANOUT_HISTORY_MAX_CHARS
        );
        const promptBlocks = buildPromptBlocks(
          displayText,
          context,
          promptContent,
          webTabBlock,
          projectContextBlock,
          historyBlock,
          projectContextUpdatesBlock
        );
        // Deliberately do NOT mark `firstPromptSent` here. Fan-out delivers the
        // first-turn project-context block only to the ephemeral sub-sessions; the
        // visible backend never receives this prompt. Consuming the flag would make
        // the next normal turn skip the block, permanently stripping the project
        // manifest from the main chat. Leaving it unset lets that turn deliver it
        // (and each ephemeral fan-out, being memoryless, re-receives it meanwhile).
        return await this.runFanoutPath(placeholderId, displayText, promptBlocks);
      }

      // Single-agent path: prepend any buffered fan-out turns as one labeled
      // prior-turn block so the backend regains continuity. Empty buffer → `null`
      // → unchanged prompt. Cleared only after `backend.prompt()` resolves below,
      // so a thrown prompt preserves the buffer for the next turn.
      const leadingContextBlock = buildPriorFanoutContextBlock(this.pendingFanoutContext);
      const promptBlocks = buildPromptBlocks(
        displayText,
        context,
        promptContent,
        webTabBlock,
        projectContextBlock,
        leadingContextBlock,
        projectContextUpdatesBlock
      );

      const req: PromptInput = {
        sessionId,
        prompt: promptBlocks,
      };
      const promptStarted = !signal.aborted;
      let resp: PromptOutput = { stopReason: "cancelled" };
      if (promptStarted) {
        const backingPrompt = this.backend.prompt(req);
        resp = await Promise.race([
          backingPrompt,
          new Promise<PromptOutput>((resolve) => {
            signal.addEventListener("abort", () => resolve({ stopReason: "cancelled" }), {
              once: true,
            });
          }),
        ]);
        if (signal.aborted) {
          const settledPrompt = backingPrompt.then(
            () => undefined,
            () => undefined
          );
          const drain = settledPrompt.then(async () => {
            let activity: number;
            do {
              activity = this.cancelledTurnActivity;
              await new Promise<void>((resolve) =>
                window.setTimeout(resolve, CANCELLED_TURN_QUIET_MS)
              );
            } while (activity !== this.cancelledTurnActivity);
          });
          this.cancelledPromptDrain = drain;
          void drain.then(() => {
            if (this.cancelledPromptDrain === drain) this.cancelledPromptDrain = null;
          });
          resp = { stopReason: "cancelled" };
        }
      }
      // Flush the buffer only on a non-cancelled completion — only then has the
      // backend durably ingested the `<prior_turns>` block. A cancelled prompt may
      // have stopped before ingesting it, so keep and re-inject (a duplicate is
      // harmless; losing the multi-agent context is the bug).
      if (leadingContextBlock !== null && resp.stopReason !== "cancelled") {
        this.pendingFanoutContext = [];
      }
      // Mark the project-context block delivered only once the backend has accepted
      // the turn, so a hard `prompt()` failure (transport/auth) leaves the flag
      // unset and the user's retry re-delivers the context (a user cancel still
      // resolves here, and the prompt did reach the backend, so it counts as delivered).
      if (isFirstTurn && promptStarted) this.firstPromptSent = true;
      // Same delivery contract as `firstPromptSent`: the note reached the backend
      // in this accepted (possibly cancelled) prompt, so advance the session's
      // cursor. A hard `prompt()` failure throws before here, leaving the cursor
      // unmoved so the retry re-delivers the note. Fan-out returns earlier and
      // never reaches this ack.
      if (projectContextUpdates && promptStarted) {
        this.markProjectContextUpdatesDeliveredFn?.(projectContextUpdates.epoch);
      }
      if (
        placeholderId &&
        resp.stopReason !== "cancelled" &&
        !this.currentTurnHadRoutedToolActivity &&
        !this.store.hasAssistantActivity(placeholderId)
      ) {
        const message = buildEmptyTurnMessage(this.backendId, resp.stopReason);
        logWarn(
          `[AgentMode] ${this.backendId} completed a turn without assistant text or tool activity (stopReason=${resp.stopReason})`
        );
        this.store.markMessageError(placeholderId, message);
      }
      if (placeholderId && this.store.markTurnComplete(placeholderId, resp.stopReason)) {
        this.notifyMessages();
      }
      // Some backends flush the prompt result before the turn's last content
      // chunks (opencode + fast DeepSeek). Keep this placeholder reachable by
      // `messageId` so those trailing chunks still land — except on an explicit
      // cancel, where further output should stay suppressed.
      if (placeholderId && resp.stopReason !== "cancelled") {
        this.settledStream = { placeholderId, messageIds: this.currentMessageIds };
      } else if (resp.stopReason === "cancelled") {
        this.settledStream = null;
      }
      this.currentMessageIds = new Set();
      if (this.placeholderId === placeholderId) this.placeholderId = null;
      if (resp.stopReason === "end_turn") void this.pollSessionTitle();
      return resp.stopReason;
    } catch (err) {
      logWarn(`[AgentMode] prompt failed`, err);
      if (placeholderId) {
        this.store.markMessageError(placeholderId, formatPromptFailure(err));
        this.notifyMessages();
      }
      this.lastTurnError = true;
      this.currentMessageIds = new Set();
      if (this.placeholderId === placeholderId) this.placeholderId = null;
      throw err;
    } finally {
      // Clearing `abortController` flips the derived status off `"running"`
      // (to `"error"` if `lastTurnError`, else `"idle"`). Recompute after
      // both the success path (no error) and the catch path (error set) so
      // listeners see the single transition out of the in-flight state.
      this.abortController = null;
      this.recomputeStatusIfChanged();
    }
  }

  /**
   * Authoritative entitlement gate for the fan-out path, at the session send
   * boundary so a UI bypass can't evade it. Delegates to the shared helper:
   * paying users allow sync with no network call; otherwise it re-verifies
   * against `/license`.
   */
  private ensureMultiAgentEntitlement(): Promise<boolean> {
    return ensureMultiAgentEntitlement(this.getApp?.(), { feature: "multi_agent_per_turn" });
  }

  /**
   * Clean up a paywall-blocked fan-out turn: surface the upgrade prompt and
   * finalize the placeholder as an error so no dangling bubble remains.
   */
  private blockFanoutForEntitlement(placeholderId: string): StopReason {
    showMultiAgentUpgradePrompt();
    this.store.markMessageError(
      placeholderId,
      "Multi-agent QA is a Copilot Plus feature. Upgrade to mention more than one agent in a turn."
    );
    this.store.markTurnComplete(placeholderId, "refusal");
    this.currentMessageIds = new Set();
    if (this.placeholderId === placeholderId) this.placeholderId = null;
    this.notifyMessages();
    return "refusal";
  }

  /**
   * Dispatch a fan-out turn. Every ANSWERER runs the identical `promptBlocks` in
   * a parallel ephemeral read-only sub-session, answers stream into per-agent
   * slots of one live {@link FanoutTurn}, and the main agent fills the summary
   * once they settle. Returns a `StopReason` so the surrounding `runTurn`
   * lifecycle matches the single-agent path.
   */
  private async runFanoutPath(
    placeholderId: string,
    originalPromptText: string,
    promptBlocks: PromptContent[]
  ): Promise<StopReason> {
    const signal = this.abortController?.signal ?? new AbortController().signal;
    const input: FanoutRunInput = {
      agents: this.lastMentionedAgents,
      // The summarizer is ALWAYS the session's main agent, separate from the answerers.
      mainAgent: this.backendId,
      prompt: withReadOnlyPreamble(promptBlocks),
      // The raw question fed to the summary. Distinct from `prompt`, which carries
      // the read-only preamble + context.
      originalPromptText,
      signal,
      onChange: (turn) => {
        // Live state rides on the placeholder's `message.fanout`; `setFanout` bumps
        // the message version so the dropdown re-renders per streamed slot.
        this.store.setFanout(placeholderId, turn);
        this.scheduleNotifyMessages();
      },
    };
    const turn = await this.runFanoutTurn!(input);
    this.store.setFanout(placeholderId, turn);

    const stopReason: StopReason = signal.aborted ? "cancelled" : "end_turn";
    // Persist the FULL composite as the message body so the dropdown reconstructs
    // on reload. Any non-empty slot text counts (including a terminal slot's
    // partial text), so a turn cancelled mid-stream is saved, not dropped blank;
    // a turn with no content at all persists/buffers nothing.
    const hasAnswerText = Object.values(turn.answers).some((a) => a.text.trim().length > 0);
    const hasContent = turn.summary.text.trim().length > 0 || hasAnswerText;
    if (hasContent) {
      const composite = serializeFanoutComposite(turn, (id) => this.displayNameFor(id));
      this.store.appendAgentText(placeholderId, composite);
      // Buffer this turn so the next single-agent prompt can replay it (the visible
      // backend never saw it). Prefer the summary ONLY when it generated
      // successfully; an incomplete (cancelled/errored) summary falls back to
      // replaying the agents' answers so a follow-up keeps the content the user saw.
      const summaryText = turn.summary.text.trim();
      const replay =
        turn.summary.complete && summaryText.length > 0
          ? summaryText
          : hasAnswerText
            ? renderFanoutComposite(turn, (id) => this.displayNameFor(id))
            : "";
      if (replay) {
        this.pendingFanoutContext.push({ question: originalPromptText, summary: replay });
      }
    }
    if (this.store.markTurnComplete(placeholderId, stopReason)) {
      this.notifyMessages();
    }
    if (this.placeholderId === placeholderId) this.placeholderId = null;
    return stopReason;
  }

  /**
   * The visible transcript EXCLUDING the current turn's user message and
   * placeholder, by exact id. Used to render the fan-out conversation-history
   * block from PRIOR conversation only. Excluding by identity (not position)
   * stays correct if anything is inserted between or after those messages.
   */
  private priorDisplayMessages(
    userMessageId: string,
    placeholderId: string
  ): readonly AgentChatMessage[] {
    return this.store
      .getDisplayMessages()
      .filter((m) => m.id !== userMessageId && m.id !== placeholderId);
  }

  /**
   * Resolve a `BackendId` to its display name via the injected resolver (id
   * fallback). Mirrors the `fanoutDropdown` resolver so heading and tab agree.
   */
  private displayNameFor(backendId: BackendId): string {
    return this.getDisplayName?.(backendId) ?? backendId;
  }

  /**
   * Cancel any in-flight turn. Local cancellation settles the prompt even if
   * the backend ignores ACP `session/cancel`; the backend notification still
   * runs so a compliant agent can stop work and keep its session reusable.
   *
   * Flushes any pending tool-permission and AskUserQuestion resolvers (rejects
   * / empty answers respectively) so the inline cards disappear immediately
   * (and the SDK sees a deny rather than a dangling promise) instead of waiting
   * for the user to click them.
   */
  async cancel(): Promise<void> {
    const status = this.getStatus();
    if (status !== "running" && status !== "awaiting_permission") return;
    if (!this.backendSessionId) return;
    this.abortController?.abort();
    this.settledStream = null;
    for (const messageId of this.currentMessageIds) this.cancelledMessageIds.add(messageId);
    this.currentMessageIds = new Set();
    if (this.pendingToolResolvers.size > 0) {
      this.flushResolvers(this.pendingToolResolvers);
      this.notifyMessages();
    }
    if (this.pendingQuestionResolvers.size > 0) {
      this.flushQuestionResolvers();
      this.notifyMessages();
    }
    try {
      await this.backend.cancel({ sessionId: this.backendSessionId });
    } catch (e) {
      logWarn(`[AgentMode] cancel notification failed`, e);
    }
  }

  /** Detach from the backend. Does not cancel — call `cancel()` first. */
  async dispose(): Promise<void> {
    this.disposed = true;
    this.unregisterSessionHandler?.();
    this.unregisterSessionHandler = null;
    this.flushResolvers(this.pendingPlanResolvers);
    this.flushResolvers(this.pendingToolResolvers);
    this.flushQuestionResolvers();
    this.decidedPlanToolCallIds.clear();
    this.currentPlan = null;
    this.currentTodoList = null;
    this.currentTodoListSignature = null;
    this.settledStream = null;
    this.cancelledPromptDrain = null;
    this.cancelledMessageIds.clear();
    this.currentMessageIds = new Set();
    // Fire the `"closed"` transition before clearing listeners so
    // subscribers still observe it. `disposed = true` above guarantees
    // `getStatus()` returns `"closed"` regardless of the other primitives.
    this.recomputeStatusIfChanged();
    this.cancelScheduledNotify();
    this.listeners.clear();
  }

  /**
   * Called by the wrapped permission prompter when an ExitPlanMode permission
   * request arrives. Returns a promise the backend will await for the
   * outcome; resolved by the chat card via `resolvePlanProposalPermission`.
   */
  handlePlanProposalPermission(request: PermissionPrompt): Promise<PermissionDecision> {
    const toolCallId = request.toolCall.toolCallId;
    const exitPlan = tryReadExitPlanModeCall({
      kind: request.toolCall.kind,
      rawInput: request.toolCall.rawInput,
      isPlanProposal: request.toolCall.isPlanProposal,
    });
    if (exitPlan) {
      this.publishGatedPlan(toolCallId, exitPlan);
    }
    return new Promise<PermissionDecision>((resolve) => {
      this.pendingPlanResolvers.set(toolCallId, { request, resolve });
      this.recomputeStatusIfChanged();
      this.notifyMessages();
    });
  }

  /**
   * Resolve a pending ExitPlanMode permission. `allow: true` selects the
   * first allow_once option; `false` selects the first reject option (or
   * cancels when no reject option is offered). When denying, an optional
   * `denyMessage` is forwarded as the agent-visible deny reason — used by
   * the plan card to ride user feedback through the same `canUseTool` deny
   * instead of as a separate follow-up turn. No-op when no permission is
   * pending for the given id (e.g. non-gated OpenCode proposals).
   */
  resolvePlanProposalPermission(toolCallId: string, allow: boolean, denyMessage?: string): void {
    const entry = this.pendingPlanResolvers.get(toolCallId);
    if (!entry) return;
    this.pendingPlanResolvers.delete(toolCallId);
    const base = decisionFor(
      entry.request,
      allow ? PERMISSION_ALLOW_KINDS : PERMISSION_REJECT_KINDS
    );
    const decision: PermissionDecision = !allow && denyMessage ? { ...base, denyMessage } : base;
    entry.resolve(decision);
    this.recomputeStatusIfChanged();
    this.notifyMessages();
  }

  /** Snapshot of the singleton plan, or `null` if there's nothing to review. */
  getCurrentPlan(): CurrentPlan | null {
    return this.currentPlan;
  }

  /**
   * The live execution todo list, or `null` when the session has none (no
   * update yet, the agent cleared it, or the session was resumed — the
   * snapshot is live-only; persistence never stores it). Returns the held
   * array reference so React subscribers don't tear on unrelated ticks.
   */
  getCurrentTodoList(): AgentTodoListEntry[] | null {
    return this.currentTodoList;
  }

  /** Latest token-usage snapshot, or `null` when the session has none yet. */
  getSessionUsage(): SessionUsage | null {
    return this.currentUsage;
  }

  /**
   * Seed the usage snapshot from persisted frontmatter on resume, so a reopened
   * chat shows its last-known usage immediately instead of blank-until-next-turn.
   * A live `usage_update` later supersedes it via {@link applyUsageUpdate}.
   */
  seedSessionUsage(usage: SessionUsage | undefined): void {
    if (!usage) return;
    this.currentUsage = usage;
    this.notifyMessages();
  }

  /**
   * Apply an incoming usage snapshot. A snapshot without a `contextWindow` is a
   * cumulative/count-only fallback (e.g. ACP's prompt-result totals, which count
   * lifetime tokens across the session), not current-context occupancy. Once we
   * already hold an occupancy snapshot (one that carries a window), ignore the
   * windowless one rather than pair its lifetime tokens with that window and
   * render a bogus percentage ring. A later live occupancy update supersedes it.
   */
  private applyUsageUpdate(usage: SessionUsage): void {
    if (usage.contextWindow === undefined && this.currentUsage?.contextWindow !== undefined) {
      return;
    }
    this.currentUsage = usage;
    this.notifyMessages();
  }

  /**
   * Drop the current plan once the user has decided. The UI gates the card
   * render on `decision === "pending"`, so a terminal state is never visible
   * to the user — clearing synchronously is fine.
   */
  finalizePlanDecision(proposalId: string): boolean {
    if (!this.currentPlan || this.currentPlan.id !== proposalId) return false;
    if (this.currentPlan.pendingToolCallId) {
      this.decidedPlanToolCallIds.add(this.currentPlan.pendingToolCallId);
    }
    this.currentPlan = null;
    this.notifyCurrentPlanChanged();
    return true;
  }

  private setCurrentPlan(next: Omit<CurrentPlan, "id" | "revision" | "decision">): void {
    if (!this.currentPlan) {
      this.planSeq += 1;
      this.currentPlan = {
        ...next,
        id: `plan-${this.internalId}-${this.planSeq}`,
        revision: 1,
        decision: "pending",
      };
      this.notifyCurrentPlanChanged();
      return;
    }
    // Bump revision only when the body actually changed. Gating /
    // pendingToolCallId / sourceFilePath flips are control-flow signals,
    // not "the plan was revised in place" — they must propagate without
    // resetting the per-tab `decided` state in `PlanPreviewView`, which
    // keys on revision. MarkdownRenderer perf for body-identical
    // republishes is already handled by React's primitive equality on
    // the `planMarkdown` string in the consumer's effect deps.
    const prev = this.currentPlan;
    const bodyChanged = prev.body !== next.body;
    const changed =
      bodyChanged ||
      prev.title !== next.title ||
      prev.sourceFilePath !== next.sourceFilePath ||
      prev.permissionGated !== next.permissionGated ||
      prev.pendingToolCallId !== next.pendingToolCallId ||
      prev.decision !== "pending";
    if (!changed) return;
    this.currentPlan = {
      ...prev,
      ...next,
      revision: bodyChanged ? prev.revision + 1 : prev.revision,
      decision: "pending",
    };
    this.notifyCurrentPlanChanged();
  }

  /** Public — used by mode-picker plumbing to clear the card on mode switch. */
  clearCurrentPlanIfModeLeft(): void {
    if (!this.currentPlan) return;
    const descriptor = this.getDescriptor?.();
    if (!descriptor) return;
    if (!descriptor.getModeMapping) return;
    if (this.isCurrentlyInPlanMode()) return;
    const pending = this.currentPlan.pendingToolCallId;
    if (pending) {
      this.resolvePlanProposalPermission(pending, false);
      this.decidedPlanToolCallIds.add(pending);
    }
    this.currentPlan = null;
    this.notifyCurrentPlanChanged();
  }

  /**
   * Resolve once the session reaches a terminal state for the current turn
   * (`idle`, `error`, or `closed`). Used by the UI orchestrator to await
   * completion of a permission-resolution-then-followup sequence.
   */
  waitForIdle(): Promise<void> {
    const terminal = (s: AgentSessionStatus) => s === "idle" || s === "error" || s === "closed";
    if (this.disposed || terminal(this.getStatus())) return Promise.resolve();
    return new Promise((resolve) => {
      const unsub = this.subscribe({
        onMessagesChanged: () => {},
        onStatusChanged: (s) => {
          if (terminal(s)) {
            unsub();
            resolve();
          }
        },
      });
    });
  }

  /**
   * Whether this session has any pending ExitPlanMode permission. The chat
   * input disables itself while one is outstanding so the user is funneled
   * toward the proposal card's actions.
   */
  hasPendingPlanPermission(): boolean {
    return this.pendingPlanResolvers.size > 0;
  }

  /**
   * Called by the wrapped permission prompter for any non-plan tool call. The
   * returned promise is resolved when the inline `ToolPermissionCard` calls
   * `resolveToolPermission` with the user's chosen option.
   */
  handleToolPermission(request: PermissionPrompt): Promise<PermissionDecision> {
    const toolCallId = request.toolCall.toolCallId;
    return new Promise<PermissionDecision>((resolve) => {
      this.pendingToolResolvers.set(toolCallId, { request, resolve });
      this.recomputeStatusIfChanged();
      this.notifyMessages();
    });
  }

  /**
   * Resolve a pending tool permission with the option the user selected.
   * No-op when no permission is pending for the given id (stale clicks).
   */
  resolveToolPermission(toolCallId: string, optionId: string): void {
    const entry = this.pendingToolResolvers.get(toolCallId);
    if (!entry) return;
    this.pendingToolResolvers.delete(toolCallId);
    entry.resolve({ outcome: { outcome: "selected", optionId } });
    this.recomputeStatusIfChanged();
    this.notifyMessages();
  }

  /**
   * Snapshot of every pending tool-permission request, in arrival order so
   * the UI renders cards stably (oldest at the top). Returns a shared empty
   * array when nothing is pending so React subscribers don't re-render on
   * unrelated ticks (a fresh `Array.from` would always change identity).
   */
  getPendingToolPermissions(): PermissionPrompt[] {
    if (this.pendingToolResolvers.size === 0) return EMPTY_PERMISSIONS;
    return Array.from(this.pendingToolResolvers.values(), (e) => e.request);
  }

  /**
   * Called by the wrapped ask-question prompter when the backend invokes its
   * inline-question surface (Claude SDK's `AskUserQuestion`). The returned
   * promise resolves when the inline `AskUserQuestionCard` calls
   * `resolveAskUserQuestion` with the user's answers — or with `EMPTY_ANSWERS`
   * if the turn is cancelled/disposed, which the bridge maps to a deny.
   */
  handleAskUserQuestion(request: AskUserQuestionPrompt): Promise<AgentQuestionAnswers> {
    const requestId = request.requestId;
    return new Promise<AgentQuestionAnswers>((resolve) => {
      this.pendingQuestionResolvers.set(requestId, { request, resolve });
      this.recomputeStatusIfChanged();
      this.notifyMessages();
    });
  }

  /**
   * Resolve a pending AskUserQuestion with the user's answers. An empty map
   * signals cancellation (the bridge turns it into the "User cancelled the
   * question" deny). No-op when no question is pending for the given id.
   */
  resolveAskUserQuestion(requestId: string, answers: AgentQuestionAnswers): void {
    const entry = this.pendingQuestionResolvers.get(requestId);
    if (!entry) return;
    this.pendingQuestionResolvers.delete(requestId);
    entry.resolve(answers);
    this.recomputeStatusIfChanged();
    this.notifyMessages();
  }

  /**
   * Snapshot of every pending AskUserQuestion request, in arrival order so the
   * UI renders cards stably. Returns a shared empty array when nothing is
   * pending so React subscribers don't re-render on unrelated ticks.
   */
  getPendingAskUserQuestions(): AskUserQuestionPrompt[] {
    if (this.pendingQuestionResolvers.size === 0) return EMPTY_QUESTIONS;
    return Array.from(this.pendingQuestionResolvers.values(), (e) => e.request);
  }

  /**
   * Reject every pending resolver in `map` with the canonical deny decision
   * derived from the request's offered options, then clear the map. Used by
   * `cancel()` and `dispose()` so the inline cards disappear and the SDK
   * turn unblocks instead of leaving a dangling promise.
   */
  private flushResolvers(
    map: Map<string, { request: PermissionPrompt; resolve: (resp: PermissionDecision) => void }>
  ): void {
    for (const { request, resolve } of map.values()) {
      resolve(decisionFor(request, PERMISSION_REJECT_KINDS));
    }
    map.clear();
    this.recomputeStatusIfChanged();
  }

  /**
   * Resolve every pending AskUserQuestion with `EMPTY_ANSWERS` (the
   * cancellation signal) and clear the map. The answer-shaped resolvers can't
   * reuse `flushResolvers`, which deals in `PermissionDecision`.
   */
  private flushQuestionResolvers(): void {
    for (const { resolve } of this.pendingQuestionResolvers.values()) {
      resolve(EMPTY_ANSWERS);
    }
    this.pendingQuestionResolvers.clear();
    this.recomputeStatusIfChanged();
  }

  private handleSessionEvent(event: SessionEvent): void {
    const update = event.update;
    const toolOwnerMessageId =
      update.sessionUpdate === "tool_call_update"
        ? this.store.findMessageIdWithToolCall(update.toolCallId)
        : undefined;
    const toolOwnerStopReason = toolOwnerMessageId
      ? this.store.getMessage(toolOwnerMessageId)?.turnStopReason
      : undefined;
    // A completed non-cancelled message belongs to an earlier turn. Its
    // background task may settle while a later prompt is being cancelled, so
    // preserve that update while continuing to suppress the cancelled turn's
    // own tool activity.
    const isPriorToolUpdate =
      toolOwnerMessageId !== undefined &&
      toolOwnerStopReason !== undefined &&
      toolOwnerStopReason !== "cancelled";

    if (this.cancelledPromptDrain || this.abortController?.signal.aborted) {
      switch (update.sessionUpdate) {
        case "agent_message_chunk":
        case "agent_thought_chunk":
          this.cancelledTurnActivity += 1;
          if (update.messageId) this.cancelledMessageIds.add(update.messageId);
          logWarn(
            `[AgentMode] dropping ${update.sessionUpdate} from cancelled turn ${this.internalId}`
          );
          return;
        case "tool_call":
        case "plan":
          this.cancelledTurnActivity += 1;
          logWarn(
            `[AgentMode] dropping ${update.sessionUpdate} from cancelled turn ${this.internalId}`
          );
          return;
        case "tool_call_update":
          if (isPriorToolUpdate) break;
          this.cancelledTurnActivity += 1;
          logWarn(
            `[AgentMode] dropping ${update.sessionUpdate} from cancelled turn ${this.internalId}`
          );
          return;
      }
    }

    // Refresh the live todo snapshot before any placeholder gating — the
    // snapshot is session-scoped state, not part of the message trail.
    if (update.sessionUpdate === "plan" && this.applyCurrentTodoList(update.entries)) {
      this.notifyCurrentTodoListChanged();
    }

    // Session-scoped updates aren't tied to a turn placeholder.
    if (update.sessionUpdate === "session_info_update") {
      // Only trust a pushed title from a backend that actually summarizes
      // (opencode). codex would push its raw-prompt-derived title here.
      if (this.backendSummarizesTitle()) this.applyAgentLabel(update.title);
      return;
    }
    if (update.sessionUpdate === "state_changed") {
      this.currentState = update.state;
      this.notifyModelChanged();
      this.clearCurrentPlanIfModeLeft();
      return;
    }
    if (update.sessionUpdate === "current_mode_update") {
      // The backend will follow this with a `state_changed` event carrying
      // the recomputed BackendState; nothing to do here.
      return;
    }
    if (update.sessionUpdate === "config_option_update") {
      // Same — the `state_changed` follow-up carries the recomputed state.
      return;
    }
    if (update.sessionUpdate === "usage_update") {
      this.applyUsageUpdate(update.usage);
      return;
    }

    // Content chunks can trail past the prompt result on some backends (the
    // result is flushed before the turn's final chunks — opencode + fast
    // DeepSeek). Route them by `messageId` so a late chunk still lands on its
    // own message, even after the turn settled and `placeholderId` was cleared,
    // and without leaking onto a newer turn's placeholder.
    if (
      update.sessionUpdate === "agent_message_chunk" ||
      update.sessionUpdate === "agent_thought_chunk"
    ) {
      const text = extractText(update.content);
      if (!text) return;
      const target = this.resolveContentTarget(update.messageId);
      if (!target) {
        logWarn(`[AgentMode] dropping ${update.sessionUpdate} — no target for ${this.internalId}`);
        return;
      }
      const appended =
        update.sessionUpdate === "agent_message_chunk"
          ? this.store.appendAgentText(target, text)
          : this.store.appendAgentThought(target, text);
      if (appended) this.scheduleNotifyMessages();
      return;
    }

    const placeholderId = this.placeholderId;
    const targetMessageId = isPriorToolUpdate ? toolOwnerMessageId : placeholderId;
    if (!targetMessageId) {
      logWarn(`[AgentMode] dropping session/update — no placeholder for ${this.internalId}`);
      return;
    }

    switch (update.sessionUpdate) {
      case "tool_call": {
        const exitPlan = tryReadExitPlanModeCall({
          kind: update.kind,
          rawInput: update.rawInput,
          isPlanProposal: update.isPlanProposal,
        });
        if (exitPlan) {
          this.publishGatedPlan(update.toolCallId, exitPlan);
          if (this.store.upsertAgentPart(targetMessageId, toolCallToPart(update))) {
            this.scheduleNotifyMessages();
          }
          return;
        }
        if (this.store.upsertAgentPart(targetMessageId, toolCallToPart(update))) {
          this.scheduleNotifyMessages();
        }
        return;
      }
      case "tool_call_update": {
        // A background launch can settle during a later prompt; route the
        // update to the message that owns the tool call so the original card
        // settles instead of a duplicate appearing on the current turn.
        if (targetMessageId !== placeholderId) this.currentTurnHadRoutedToolActivity = true;
        const existing = this.findToolCallPart(targetMessageId, update.toolCallId);
        const merged = mergeToolCallUpdate(existing, update);
        if (merged.kind === "tool_call") {
          const exitPlan = tryReadExitPlanModeCall({
            kind: update.kind ?? merged.toolKind,
            rawInput: merged.input,
            isPlanProposal: update.isPlanProposal,
          });
          if (exitPlan) {
            this.publishGatedPlan(merged.id, exitPlan);
          }
        }
        if (this.store.upsertAgentPart(targetMessageId, merged)) {
          this.scheduleNotifyMessages();
        }
        return;
      }
      case "plan": {
        if (this.store.upsertAgentPart(targetMessageId, planToPart(update))) {
          this.scheduleNotifyMessages();
        }
        return;
      }
      default:
        logInfo(
          `[AgentMode] ignoring session/update kind=${(update as { sessionUpdate: string }).sessionUpdate}`
        );
        return;
    }
  }

  /**
   * Pick the message a content chunk should append to. A chunk whose
   * `messageId` belongs to the just-settled turn routes there — this covers
   * backends that flush the `session/prompt` result before the turn's final
   * chunks, so the tail isn't dropped. Otherwise the chunk belongs to the
   * in-flight turn's placeholder, and we record its `messageId` so the same
   * message's later chunks stay routable once the turn settles. Returns null
   * when there's no message to append to (a genuinely stray update).
   */
  private resolveContentTarget(messageId: string | undefined): string | null {
    if (
      this.cancelledPromptDrain ||
      this.abortController?.signal.aborted ||
      (messageId !== undefined && this.cancelledMessageIds.has(messageId))
    ) {
      return null;
    }
    if (messageId && this.settledStream?.messageIds.has(messageId)) {
      return this.settledStream.placeholderId;
    }
    const placeholderId = this.placeholderId;
    if (!placeholderId) return null;
    if (messageId) this.currentMessageIds.add(messageId);
    return placeholderId;
  }

  private findToolCallPart(messageId: string, toolCallId: string): AgentMessagePart | undefined {
    const msg = this.store.getMessage(messageId);
    return msg?.parts?.find((p) => p.kind === "tool_call" && p.id === toolCallId);
  }

  /**
   * Handle a fresh `ExitPlanMode` tool_call: open or revise the floating
   * plan card with the new body and route the gated permission resolver
   * id at it.
   */
  private publishGatedPlan(
    toolCallId: string,
    info: { plan: string; planFilePath?: string }
  ): void {
    if (this.decidedPlanToolCallIds.has(toolCallId)) return;
    const stale = this.currentPlan?.pendingToolCallId;
    if (stale && stale !== toolCallId) this.resolvePlanProposalPermission(stale, false);
    const title = derivePlanTitleFromMarkdown(info.plan);
    this.setCurrentPlan({
      body: info.plan,
      title,
      sourceFilePath: info.planFilePath,
      permissionGated: true,
      pendingToolCallId: toolCallId,
    });
  }

  /** Whether the session is currently in canonical plan mode. */
  private isCurrentlyInPlanMode(): boolean {
    return this.currentState?.mode?.current === "plan";
  }

  /**
   * Recompute the derived status and fire `onStatusChanged` if it changed
   * since the last time we fired. The cache (`cachedStatus`) exists purely
   * for change detection — never read it as truth; always call
   * `getStatus()`.
   *
   * Call this after mutating any primitive that participates in the
   * derivation: `disposed`, `backendSessionId`, `startupFailed`,
   * `abortController`, `lastTurnError`, or the resolver maps.
   */
  private recomputeStatusIfChanged(): void {
    const next = this.getStatus();
    if (next === this.cachedStatus) return;
    this.cachedStatus = next;
    for (const l of this.listeners) {
      try {
        l.onStatusChanged(next);
      } catch (e) {
        logWarn(`[AgentMode] status listener threw`, e);
      }
    }
  }

  private notifyMessages(): void {
    this.cancelScheduledNotify();
    for (const l of this.listeners) {
      try {
        l.onMessagesChanged();
      } catch (e) {
        logWarn(`[AgentMode] messages listener threw`, e);
      }
    }
  }

  /**
   * Coalesced variant for streaming hot paths. Multiple calls within a
   * single animation frame collapse to one `onMessagesChanged` fire. Store
   * mutations have already happened synchronously, so subscribers see the
   * latest state on their next render — they just see fewer renders.
   */
  private scheduleNotifyMessages(): void {
    if (this.notifyScheduled) return;
    this.notifyScheduled = true;
    const fire = (): void => {
      this.notifyHandle = null;
      this.notifyScheduled = false;
      this.notifyMessages();
    };
    if (typeof requestAnimationFrame !== "undefined") {
      this.notifyHandle = window.requestAnimationFrame(fire);
    } else {
      this.notifyHandle = window.setTimeout(fire, 16);
    }
  }

  private cancelScheduledNotify(): void {
    if (!this.notifyScheduled) return;
    this.notifyScheduled = false;
    if (this.notifyHandle !== null) {
      if (typeof cancelAnimationFrame !== "undefined" && typeof this.notifyHandle === "number") {
        cancelAnimationFrame(this.notifyHandle);
      } else {
        window.clearTimeout(this.notifyHandle as ReturnType<typeof setTimeout>);
      }
      this.notifyHandle = null;
    }
  }

  /**
   * Pull the agent-generated title for this session via `listSessions` and
   * apply it as the tab label. Best-effort: silently no-ops when the agent
   * doesn't support listing or when the title is still the default.
   */
  private async pollSessionTitle(): Promise<void> {
    if (this.labelSource === "user") return;
    // Only backends that summarize return a clean title; for the rest the tab
    // label is derived client-side from the first user message (see sendPrompt).
    if (!this.backendSummarizesTitle()) return;
    try {
      const resp = await this.backend.listSessions(this.cwd ? { cwd: this.cwd } : {});
      const entry = resp.sessions.find((s) => s.sessionId === this.backendSessionId);
      const title = entry?.title?.trim();
      if (!title) return;
      if (title.startsWith(DEFAULT_TITLE_PREFIX)) return;
      this.applyAgentLabel(title);
    } catch (err) {
      if (err instanceof MethodUnsupportedError) return;
      logWarn(`[AgentMode] session/list title poll failed for ${this.internalId}`, err);
    }
  }

  private notifyModelChanged(): void {
    for (const l of this.listeners) {
      try {
        l.onModelChanged?.();
      } catch (e) {
        logWarn(`[AgentMode] model listener threw`, e);
      }
    }
  }

  /**
   * Apply a `plan` update's entries to the live todo snapshot. Returns true
   * when the canonical content actually changed (signature compare) — equal
   * lists from redundant updates (opencode's synthesized + real plan channel,
   * Claude's multiple stream injection points) are dropped silently. An empty
   * entries list clears the snapshot back to `null`.
   *
   * Layer 2 of 3 in the todo-plan dedup chain — the SNAPSHOT layer: suppresses
   * no-op `onCurrentTodoListChanged` ticks for the Progress section. It is NOT
   * redundant with the others: the emit layer (`claudeTodoPlan.emitIfChanged`)
   * collapses one backend's repeated injections, and `planEntriesEqual`
   * (AgentMessageStore) dedups the rendered plan message part — this layer is
   * the only one guarding the live snapshot's listeners, and the only one that
   * sees ALL backends' plan updates converged.
   */
  private applyCurrentTodoList(entries: AgentPlanEntry[]): boolean {
    const next: AgentTodoListEntry[] = entries.map((e) => ({
      content: e.content,
      status: e.status,
    }));
    const signature = next.length > 0 ? JSON.stringify(next) : null;
    if (signature === this.currentTodoListSignature) return false;
    this.currentTodoList = next.length > 0 ? next : null;
    this.currentTodoListSignature = signature;
    return true;
  }

  private notifyCurrentTodoListChanged(): void {
    for (const l of this.listeners) {
      try {
        l.onCurrentTodoListChanged?.();
      } catch (e) {
        logWarn(`[AgentMode] todo-list listener threw`, e);
      }
    }
  }

  private notifyCurrentPlanChanged(): void {
    for (const l of this.listeners) {
      try {
        l.onCurrentPlanChanged?.();
      } catch (e) {
        logWarn(`[AgentMode] plan listener threw`, e);
      }
    }
  }
}

/** Build the visible message for a completed turn that produced no UI activity. */
function buildEmptyTurnMessage(backendId: BackendId, stopReason: StopReason): string {
  return `${backendId} finished the turn without returning any assistant text or tool activity (stop reason: ${stopReason}). Try again, or switch models if this repeats.`;
}

/** Format prompt failures with provider error details when available. */
function formatPromptFailure(err: unknown): string {
  const base = err2String(err);
  const providerMessage = extractProviderErrorMessage(err);
  if (!providerMessage || base.includes(providerMessage)) return base;
  return `${base}\n${providerMessage}`;
}

/** Extract concise model/provider errors nested inside AI SDK error objects. */
function extractProviderErrorMessage(err: unknown): string | null {
  const found = findProviderErrorPayload(err, new Set<unknown>());
  if (!found) return null;
  const type = typeof found.type === "string" ? found.type : "ProviderError";
  const message = typeof found.message === "string" ? found.message : null;
  if (!message) return type;
  return `${type}: ${message}`;
}

/** Recursively search common AI SDK error shapes for provider error payloads. */
function findProviderErrorPayload(
  value: unknown,
  seen: Set<unknown>
): { type?: unknown; message?: unknown } | null {
  if (value === null || typeof value !== "object") return null;
  if (seen.has(value)) return null;
  seen.add(value);
  const record = value as Record<string, unknown>;
  const directError = record.error;
  if (directError && typeof directError === "object") {
    const errorRecord = directError as Record<string, unknown>;
    if (typeof errorRecord.message === "string" || typeof errorRecord.type === "string") {
      return { type: errorRecord.type, message: errorRecord.message };
    }
  }
  // Some agents (e.g. codex-acp) stringify the upstream provider error as JSON
  // inside a `message` field. Parse and recurse so the real reason surfaces
  // instead of a bare "Internal error".
  if (typeof record.message === "string") {
    const parsed = tryParseJsonObject(record.message);
    if (parsed) {
      const nested = findProviderErrorPayload(parsed, seen);
      if (nested) return nested;
    }
  }
  if (record.data) {
    const nested = findProviderErrorPayload(record.data, seen);
    if (nested) return nested;
  }
  if (Array.isArray(record.errors)) {
    for (const item of record.errors) {
      const nested = findProviderErrorPayload(item, seen);
      if (nested) return nested;
    }
  }
  const cause = record.cause;
  if (cause) return findProviderErrorPayload(cause, seen);
  return null;
}

/** Parse a string into a plain object, or null if it isn't JSON-object-shaped. */
function tryParseJsonObject(s: string): Record<string, unknown> | null {
  const t = s.trim();
  if (!t.startsWith("{")) return null; // cheap guard; don't parse plain text
  try {
    const v = JSON.parse(t);
    return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

type DisplayContentItem =
  | { type: "text"; text: string }
  | { type: "image_url"; image_url: { url: string } };

/**
 * Project a user prompt's text + outgoing image blocks into the display
 * `content` shape the chat renderer understands: `ChatSingleMessage` renders a
 * `content` array of `text` and `image_url` entries (text first, then images,
 * mirroring the legacy chat composer). The backend still consumes the original
 * `PromptContent` image blocks (base64); this is a display-only projection so
 * attached images show in the posted user bubble.
 *
 * Returns undefined when there are no images — text-only messages render via the
 * renderer's plain `message` fallback and don't need a `content` array. When
 * images are present, the text entry is included only if there is prompt text,
 * so an image-only message doesn't render an empty text line.
 */
export function buildUserDisplayContent(
  displayText: string,
  promptContent?: PromptContent[]
): DisplayContentItem[] | undefined {
  const images = (promptContent ?? []).filter(
    (p): p is Extract<PromptContent, { type: "image" }> => p.type === "image"
  );
  if (images.length === 0) return undefined;
  const content: DisplayContentItem[] = [];
  if (displayText.trim()) content.push({ type: "text", text: displayText });
  for (const img of images) {
    content.push({
      type: "image_url",
      image_url: { url: `data:${img.mimeType};base64,${img.data}` },
    });
  }
  return content;
}

export function buildPromptBlocks(
  displayText: string,
  context?: MessageContext,
  content?: PromptContent[],
  webTabBlock?: string,
  projectContextBlock?: string | null,
  leadingContextBlock?: string | null,
  projectContextUpdatesBlock?: string | null
): PromptContent[] {
  // Context sections precede the user message: the project-context block (first
  // user prompt only — the project's folders/notes/URLs), then the coarse
  // "sources may have changed" updates note (ongoing/resumed turns), then an
  // optional prior-turn block (buffered fan-out turns the backend never saw), the
  // vault envelope (attached notes + note excerpts), web-selection excerpts, then
  // live web-tab content. Web tab/selection blocks reuse the legacy `<web_*>` tags
  // so the model reads the same shapes it does in the non-agent chat.
  const sections = [
    projectContextBlock?.trim() || null,
    projectContextUpdatesBlock?.trim() || null,
    leadingContextBlock?.trim() || null,
    buildContextEnvelope(context),
    buildWebSelectionBlocks(context),
    webTabBlock?.trim() || null,
  ].filter((s): s is string => Boolean(s));
  const head = sections.length > 0 ? sections.join("\n\n") : null;
  const headText = head
    ? `${head}\n\n<user-message>\n${displayText}\n</user-message>`
    : displayText;
  const extras = content ?? [];
  if (extras.length === 0) return [{ type: "text", text: headText }];
  return [{ type: "text", text: headText }, ...extras];
}

/**
 * Prepend the read-only QA instruction to the shared prompt blocks for a fan-out
 * turn. Leads the first text block (or a new one) so every agent reads the same
 * read-only framing before the context. Identical for every agent.
 */
export function withReadOnlyPreamble(blocks: PromptContent[]): PromptContent[] {
  const i = blocks.findIndex((b) => b.type === "text");
  if (i === -1) return [{ type: "text", text: FANOUT_READONLY_PREAMBLE }, ...blocks];
  const block = blocks[i] as Extract<PromptContent, { type: "text" }>;
  const out = blocks.slice();
  out[i] = { type: "text", text: `${FANOUT_READONLY_PREAMBLE}\n\n${block.text}` };
  return out;
}

/**
 * Extract live Web Viewer content for the attached web tabs, reusing the
 * non-agent chat's processor (reader-mode markdown, YouTube transcripts,
 * timeouts, dedup, availability handling). Returns "" when there are no tabs.
 */
async function serializeWebTabContext(context: MessageContext | undefined): Promise<string> {
  const webTabs = context?.webTabs;
  if (!webTabs || webTabs.length === 0) return "";
  return (await ContextProcessor.getInstance().processContextWebTabs(webTabs)).trim();
}

/**
 * Serialize web-page text selections as `<web_selected_text>` blocks. The
 * content is already captured at selection time, so this is synchronous (no
 * webview round-trip). Note excerpts are handled by {@link buildContextEnvelope}.
 */
function buildWebSelectionBlocks(context: MessageContext | undefined): string | null {
  const selections = (context?.selectedTextContexts ?? []).filter(isWebSelectedTextContext);
  if (selections.length === 0) return null;
  return selections
    .map((s) =>
      [
        `<${WEB_SELECTED_TEXT_TAG}>`,
        `<title>${escapeXml(s.title)}</title>`,
        `<url>${escapeXml(s.url)}</url>`,
        `<content>\n${escapeXml(s.content)}\n</content>`,
        `</${WEB_SELECTED_TEXT_TAG}>`,
      ].join("\n")
    )
    .join("\n\n");
}

/**
 * Build the `<copilot-context>` envelope listing the vault items attached to
 * THIS message (`@notes` + selected excerpts) and inlining note excerpts.
 * Returns `null` when there's nothing to attach. Distinct from the project-wide
 * `<project_context>` block, which lists the project's configured sources.
 */
function buildContextEnvelope(context: MessageContext | undefined): string | null {
  if (!context) return null;
  const notePaths = (context.notes ?? []).map((n) => n.path).filter(Boolean);
  const excerpts = (context.selectedTextContexts ?? []).filter(isNoteSelectedTextContext);
  if (notePaths.length === 0 && excerpts.length === 0) return null;

  const lines: string[] = [
    "<copilot-context>",
    "The user attached the following vault items. The vault is your current working directory; use the Read tool to inspect them when relevant.",
  ];
  if (notePaths.length > 0) {
    lines.push("", "Notes:");
    for (const p of notePaths) lines.push(`- ${p}`);
  }
  if (excerpts.length > 0) {
    lines.push("", "Selected excerpts (already inlined; no need to re-read):");
    for (const e of excerpts) {
      lines.push(`- ${e.notePath} (lines ${e.startLine}-${e.endLine}):`);
      for (const l of e.content.split("\n")) lines.push(`  ${l}`);
    }
  }
  lines.push("</copilot-context>");
  return lines.join("\n");
}

function extractText(content: PromptContent): string {
  if (content.type === "text") return content.text;
  return "";
}

function toolCallToPart(
  call: ToolCallSnapshot & { sessionUpdate?: "tool_call" }
): AgentMessagePart {
  return {
    kind: "tool_call",
    id: call.toolCallId,
    title: call.title,
    toolKind: call.kind,
    status: call.status ?? "pending",
    input: call.rawInput,
    output: extractToolCallOutputs(call.content),
    locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })),
    vendorToolName: call.vendorToolName,
    mcpServer: call.mcpServer,
    parentToolCallId: call.parentToolCallId,
    progress: call.progress,
  };
}

/**
 * Detect whether a tool_call / tool_call_update represents the agent's
 * plan-finalization signal. Returns the parsed payload (`plan`, optional
 * `planFilePath`) when so, or `null` otherwise.
 */
export function tryReadExitPlanModeCall(args: {
  kind?: string;
  rawInput: unknown;
  isPlanProposal?: boolean;
}): { plan: string; planFilePath?: string } | null {
  const raw = args.rawInput as { plan?: unknown; planFilePath?: unknown } | null | undefined;
  const plan = raw?.plan;
  if (typeof plan !== "string") return null;
  if (!args.isPlanProposal && args.kind !== "switch_mode") return null;
  const planFilePath = typeof raw?.planFilePath === "string" ? raw.planFilePath : undefined;
  return { plan, planFilePath };
}

function derivePlanTitleFromMarkdown(md: string): string {
  for (const line of md.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed) continue;
    return trimmed.replace(/^#+\s*/, "").slice(0, 80);
  }
  return "Plan proposal";
}

function mergeToolCallUpdate(
  existing: AgentMessagePart | undefined,
  upd: ToolCallDelta & { sessionUpdate?: "tool_call_update" }
): AgentMessagePart {
  const base: AgentMessagePart =
    existing && existing.kind === "tool_call"
      ? existing
      : {
          kind: "tool_call",
          id: upd.toolCallId,
          title: upd.title ?? "Tool call",
          status: "pending",
        };
  if (base.kind !== "tool_call") return base;
  return {
    ...base,
    title: upd.title ?? base.title,
    toolKind: upd.kind ?? base.toolKind,
    status: upd.status ?? base.status,
    input: upd.rawInput !== undefined ? upd.rawInput : base.input,
    output:
      upd.content !== undefined && upd.content !== null
        ? extractToolCallOutputs(upd.content)
        : base.output,
    locations:
      upd.locations !== undefined && upd.locations !== null
        ? upd.locations.map((l) => ({ path: l.path, line: l.line ?? undefined }))
        : base.locations,
    vendorToolName: upd.vendorToolName ?? base.vendorToolName,
    mcpServer: upd.mcpServer ?? base.mcpServer,
    parentToolCallId: upd.parentToolCallId ?? base.parentToolCallId,
    progress: upd.progress === undefined ? base.progress : { ...base.progress, ...upd.progress },
  };
}

function extractToolCallOutputs(
  content: ToolCallContent[] | null | undefined
): AgentToolCallOutput[] | undefined {
  if (!content) return undefined;
  const outputs: AgentToolCallOutput[] = [];
  for (const item of content) {
    if (item.type === "content" && item.content.type === "text") {
      outputs.push(capToolOutputText(item.content.text));
    } else if (item.type === "diff") {
      outputs.push({
        type: "diff",
        path: item.path,
        oldText: item.oldText ?? null,
        newText: item.newText,
      });
    }
  }
  return outputs.length > 0 ? outputs : undefined;
}

/**
 * Pass tool-output text through untouched unless it exceeds the runaway
 * backstop ({@link MAX_TOOL_OUTPUT_TEXT_CHARS}). The marker is explicit that
 * only the *display* copy is trimmed and the agent still got everything, so a
 * user never reads it as lost data.
 */
function capToolOutputText(text: string): AgentToolCallOutput {
  if (text.length <= MAX_TOOL_OUTPUT_TEXT_CHARS) return { type: "text", text };
  const omitted = text.length - MAX_TOOL_OUTPUT_TEXT_CHARS;
  return {
    type: "text",
    text:
      text.slice(0, MAX_TOOL_OUTPUT_TEXT_CHARS) +
      `\n\n[Display trimmed: ${omitted.toLocaleString()} more characters. The agent received the full output.]`,
  };
}

/**
 * Pick the first option matching one of the given kinds (in order) and return
 * a `selected` decision. Falls back to `cancelled` (spec-safe no-decision)
 * when the agent offers no matching option.
 */
function decisionFor(
  req: PermissionPrompt,
  kinds: ReadonlyArray<PermissionOptionKind>
): PermissionDecision {
  for (const k of kinds) {
    const opt = req.options.find((o) => o.kind === k);
    if (opt) return { outcome: { outcome: "selected", optionId: opt.optionId } };
  }
  return { outcome: { outcome: "cancelled" } };
}

function planToPart(plan: PlanSummary & { sessionUpdate?: "plan" }): AgentMessagePart {
  return {
    kind: "plan",
    entries: plan.entries.map((e) => ({
      content: e.content,
      priority: e.priority,
      status: e.status,
    })),
  };
}
