/**
 * Serial Route Strategy
 * Cats respond one by one, each seeing previous responses.
 *
 * A2A support: after each cat completes, its response is checked for @mentions.
 * If a mention is detected and depth allows, the mentioned cat is appended to the
 * worklist — extending the chain within the SAME function call. This preserves
 * previousResponses continuity and correct isFinal semantics (缅因猫 P1-1, P1-2).
 *
 * A2A only triggers here in routeSerial; routeParallel never chains (MVP safety boundary).
 */

import { type CatConfig, type CatId, catRegistry, createCatId, resolveWorkflowSopSkill } from '@cat-cafe/shared';
import type { Span } from '@opentelemetry/api';
import { context, trace } from '@opentelemetry/api';
import { getCatContextBudget } from '../../../../../config/cat-budgets.js';
import { getConfigSessionStrategy, isSessionChainEnabled } from '../../../../../config/cat-config-loader.js';
import { getCatVoice } from '../../../../../config/cat-voices.js';
import { createModuleLogger } from '../../../../../infrastructure/logger.js';
import {
  AGENT_ID,
  type CallerTraceContext,
  ROUTE_HAS_A2A_HANDOFF,
  ROUTE_TOTAL_CATS_INVOKED,
  ROUTE_TOTAL_TOKENS,
} from '../../../../../infrastructure/telemetry/genai-semconv.js';
import {
  a2aDispatchCount,
  c2ExitChecked,
  c2VerdictHintEmitted,
  c2VerdictWithoutPassCount,
  c2VoidHoldChecked,
  c2VoidHoldHintEmitted,
  inlineActionChecked,
  inlineActionDetected,
  inlineActionFeedbackWriteFailed,
  inlineActionFeedbackWritten,
  inlineActionHintEmitFailed,
  inlineActionHintEmitted,
  inlineActionRoutedSetSkip,
  inlineActionShadowMiss,
  lineStartDetected,
} from '../../../../../infrastructure/telemetry/instruments.js';
import { detectUserMention } from '../../../../../routes/user-mention.js';
import { estimateTokens } from '../../../../../utils/token-counter.js';
import {
  ackGuideCompletion,
  guideContextForCat,
  prepareGuideContext,
} from '../../../../guides/GuideRoutingInterceptor.js';
import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js';
import { assembleContext } from '../../context/ContextAssembler.js';
import {
  buildInvocationContext,
  buildStaticIdentity,
  buildStaticIdentityPackOnly,
  type InvocationContext,
} from '../../context/SystemPromptBuilder.js';
import { formatDegradationMessage } from '../../orchestration/DegradationPolicy.js';
import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAuditLog.js';
import { buildSessionBootstrap } from '../../session/SessionBootstrap.js';
import {
  hydrateCrossThreadReplyHint,
  hydrateReplyPreview,
  type StoredToolEvent,
  type StreamMetadataAugmentInput,
} from '../../stores/ports/MessageStore.js';
import type { Thread, ThreadRoutingPolicyV1 } from '../../stores/ports/ThreadStore.js';
import { classifyTool } from '../../tool-usage/classify.js';
import { deriveResultSummary } from '../../tool-usage/derive-result-summary.js';
import { normalizeMcpToolName } from '../../tool-usage/normalize-mcp-tool-name.js';
import { getStreamingTtsRegistry, StreamingTtsChunker } from '../../tts/StreamingTtsChunker.js';
import { getVoiceBlockSynthesizer } from '../../tts/VoiceBlockSynthesizer.js';
import type { AgentMessage, AgentMessageType, MessageMetadata } from '../../types.js';
import { buildCapsuleFromRouteState } from '../invocation/CollaborationContinuityCapsule.js';
import { invokeSingleCat } from '../invocation/invoke-single-cat.js';
import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js';
import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js';
import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js';
import { detectInlineActionMentionsWithShadow, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js';
import {
  isSubstantiveTool,
  registerWorklist,
  unregisterWorklist,
  updateStreakOnPush,
} from '../routing/WorklistRegistry.js';
import { accumulateTextAggregate } from '../text-aggregation.js';
import { formatA2AHandoffContent } from './a2a-handoff-label.js';
import { extractContextEvalSignals } from './context-eval.js';
import { validateRoutingSyntax } from './final-routing-slot.js';
import { buildBriefingMessage } from './format-briefing.js';
import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js';
import type { RouteOptions, RouteStrategyDeps } from './route-helpers.js';
import {
  assembleIncrementalContext,
  createLeakedToolCallStreamStripper,
  detectContextDegradation,
  getService,
  getThreadBootcampMemberCount,
  isUserFacingSystemInfoContent,
  routeContentBlocksForCat,
  sanitizeInjectedContent,
  shouldAppendExplicitCurrentMessage,
  toStoredToolEvent,
  upsertMaxBoundary,
} from './route-helpers.js';
import { appendThinkingChunk, renderThinkingChunks } from './thinking-chunks.js';
import { shouldWarnVerdictWithoutPass } from './verdict-detect.js';
import { shouldWarnVoidHold } from './void-hold-detect.js';
import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_SOURCE } from './vote-intercept.js';

const log = createModuleLogger('route-serial');
const routeSerialTracer = trace.getTracer('cat-cafe-api', '0.1.0');

function collectStructuredTargetCatsFromInput(input: unknown): string[] {
  if (!input || typeof input !== 'object') return [];

  const parsed = input as { targetCats?: unknown; targets?: unknown };
  const values = Array.isArray(parsed.targetCats)
    ? parsed.targetCats
    : Array.isArray(parsed.targets)
      ? parsed.targets
      : [];
  return values.filter((value): value is string => typeof value === 'string' && value.length > 0);
}

function isPostMessageToolName(toolName: string | undefined): boolean {
  if (!toolName) return false;
  if (toolName.endsWith('cat_cafe_post_message')) return true;
  return toolName === 'mcp:cat-cafe/post_message' || toolName === 'cat_cafe_post_message';
}

type CallbackPostResult = {
  confirmed: boolean;
  messageId?: string;
  threadId?: string;
};

function collectCallbackPostResultCandidates(content: string): string[] {
  const candidates = new Set<string>();
  const trimmed = content.trim();
  if (trimmed) candidates.add(trimmed);
  for (const line of trimmed.split(/\r?\n/)) {
    const candidate = line.trim();
    if (candidate.startsWith('{') && candidate.endsWith('}')) candidates.add(candidate);
  }
  const jsonStart = trimmed.indexOf('{');
  if (jsonStart > 0) candidates.add(trimmed.slice(jsonStart));
  return [...candidates];
}

function callbackPostResultFromPayload(parsed: {
  status?: unknown;
  messageId?: unknown;
  threadId?: unknown;
}): CallbackPostResult | null {
  const confirmed = parsed.status === 'ok' || parsed.status === 'duplicate';
  if (!confirmed && parsed.status === undefined) return null;
  return {
    confirmed,
    ...(typeof parsed.messageId === 'string' && parsed.messageId.length > 0 ? { messageId: parsed.messageId } : {}),
    ...(typeof parsed.threadId === 'string' && parsed.threadId.length > 0 ? { threadId: parsed.threadId } : {}),
  };
}

function parseCallbackPostResult(content: string | undefined): {
  confirmed: boolean;
  messageId?: string;
  threadId?: string;
} {
  if (!content) return { confirmed: false };
  for (const candidate of collectCallbackPostResultCandidates(content)) {
    try {
      const parsed = JSON.parse(candidate) as { status?: unknown; messageId?: unknown; threadId?: unknown };
      const result = callbackPostResultFromPayload(parsed);
      if (result) return result;
    } catch {
      // Try the next candidate shape.
    }
  }

  return {
    confirmed: /"status"\s*:\s*"(ok|duplicate)"/.test(content),
  };
}

function inferToolResultName(msg: AgentMessage): string | undefined {
  if (msg.toolName) return msg.toolName;
  const firstLine = msg.content?.trimStart().split('\n', 1)[0]?.trim();
  if (!firstLine) return undefined;
  const mcpLabel = firstLine.match(/^(mcp:[^\s]+)\s+\(/);
  if (mcpLabel?.[1]) return mcpLabel[1];
  if (firstLine.startsWith('command: ')) return 'command_execution';
  return undefined;
}

function toolNamesMatch(a: string, b: string): boolean {
  return a === b || (isPostMessageToolName(a) && isPostMessageToolName(b));
}

function consumePendingToolResult(
  pendingToolResults: string[],
  msg: AgentMessage,
  hasConfirmingContent: boolean,
  hasCallbackPostEvidence: boolean,
): string | undefined {
  const resultToolName = inferToolResultName(msg);
  if (resultToolName) {
    const pendingIndex = pendingToolResults.findIndex((name) => toolNamesMatch(name, resultToolName));
    if (pendingIndex === -1) return undefined;
    pendingToolResults.splice(pendingIndex, 1);
    return resultToolName;
  }

  const firstPending = pendingToolResults[0];
  if (!firstPending) return undefined;

  if (!isPostMessageToolName(firstPending)) {
    return pendingToolResults.shift();
  }

  if (hasConfirmingContent && hasCallbackPostEvidence) {
    return pendingToolResults.shift();
  }

  if (hasConfirmingContent && pendingToolResults.length === 1) {
    return pendingToolResults.shift();
  }

  return undefined;
}

function hasStreamMetadataPatch(patch: StreamMetadataAugmentInput): boolean {
  return Boolean(
    patch.thinking || patch.metadata || patch.toolEvents?.length || patch.replyTo || patch.mentionsUser || patch.extra,
  );
}

export async function* routeSerial(
  deps: RouteStrategyDeps,
  targetCats: CatId[],
  message: string,
  userId: string,
  threadId: string,
  options: RouteOptions = {},
): AsyncIterable<AgentMessage> {
  const {
    contentBlocks,
    uploadDir,
    signal,
    signalForCat,
    promptTags,
    contextHistory,
    history,
    currentUserMessageId,
    a2aTriggerMessageId,
    modeSystemPrompt,
    modeSystemPromptByCat,
    queueHasQueuedMessages,
    hasQueuedOrActiveAgentForCat,
    deferA2AEnqueue,
  } = options;
  const previousResponses: { catId: CatId; content: string }[] = [];
  const thinkingMode = options.thinkingMode ?? 'play';
  // P2-3 fix: also consider default MCP server path (ClaudeAgentService has fallback resolution)
  const mcpServerPath = process.env.CAT_CAFE_MCP_SERVER_PATH || resolveDefaultClaudeMcpServerPath();
  const incrementalMode = Boolean(currentUserMessageId && deps.deliveryCursorStore);

  // Worklist pattern: starts with targetCats, may grow via A2A mentions
  // F27: Register worklist so callback A2A can push targets here
  // F108: Key by parentInvocationId for concurrent isolation
  const worklist = [...targetCats];
  const maxDepth = options.maxA2ADepth ?? getMaxA2ADepth();
  const worklistEntry = registerWorklist(threadId, worklist, maxDepth, options.parentInvocationId);

  let index = 0;
  // done-guarantee: Track whether we yielded a done(isFinal=true) so the finally block can
  // synthesize one if the loop exits early (e.g. signal.aborted break at top of while).
  let yieldedFinalDone = false;
  // F27: Track how many worklist entries have had a2a_handoff emitted
  let handoffEmitted = targetCats.length; // Original targets don't get handoff events
  const activeTrackedA2ASlots = new Set<CatId>();
  // F042 Wave 3: Fetch thread participant activity once before loop (threadId doesn't change).
  let activeParticipants: { catId: CatId; lastMessageAt: number; messageCount: number }[] = [];
  if (deps.invocationDeps.threadStore) {
    try {
      activeParticipants = await deps.invocationDeps.threadStore.getParticipantsWithActivity(threadId);
    } catch {
      /* best-effort: activity fetch failure does not block invocation */
    }
  }
  // F042: Fetch thread routingPolicy once before loop (threadId doesn't change).
  let routingPolicy: ThreadRoutingPolicyV1 | undefined;
  // F073 P4: SOP stage hint from workflow-sop (告示牌 — info only, cats decide actions)
  let sopStageHint:
    | { stage: string; suggestedSkill: string; suggestedSkillSource: string; featureId: string }
    | undefined;
  // F092: Voice companion mode
  let voiceMode: boolean | undefined;
  // F087: Bootcamp state for CVO onboarding
  let bootcampState: InvocationContext['bootcampState'];
  const targetCatIds = new Set<string>(targetCats);
  // Thread read: shared across routingPolicy, voiceMode, bootcamp, SOP, and guide interceptor
  let routeThread: Thread | null = null;
  if (deps.invocationDeps.threadStore) {
    try {
      routeThread = (await deps.invocationDeps.threadStore.get(threadId)) ?? null;
      routingPolicy = routeThread?.routingPolicy;
      voiceMode = routeThread?.voiceMode;
      bootcampState = routeThread?.bootcampState;
      // F073 P4: Read workflow-sop if thread is linked to a backlog item
      if (routeThread?.backlogItemId && deps.invocationDeps.workflowSopStore) {
        try {
          const sop = await deps.invocationDeps.workflowSopStore.get(routeThread.backlogItemId);
          if (sop) {
            const skill = resolveWorkflowSopSkill(sop);
            sopStageHint = {
              stage: sop.stage,
              suggestedSkill: skill.skill,
              suggestedSkillSource: skill.source,
              featureId: sop.featureId,
            };
          }
        } catch {
          /* best-effort: SOP hint failure does not block invocation */
        }
      }
    } catch {
      /* best-effort */
    }
  }
  const bootcampMemberCount = getThreadBootcampMemberCount(routeThread);

  // F153: Trace propagation — track per-invocation spans and route-level token totals
  const catInvocationSpans = new Map<number, Span>();
  const mentionParentSpan = new Map<number, Span>();
  const pendingDispatchSpans: { span: Span; lastChildIndex: number }[] = [];
  let routeTotalTokens = 0;

  // F155: Guide interceptor — resume existing guide state only
  const guideCtx = await prepareGuideContext({
    thread: routeThread,
    guideSessionStore: deps.invocationDeps.guideSessionStore,
    targetCats,
    message,
    userId,
    threadId,
    log,
    dismissTracker: deps.invocationDeps.dismissTracker,
  });

  const completedCatInvocationIds: Array<[string, string]> = [];

  try {
    while (index < worklist.length) {
      const catId = worklist[index]!;
      // F-parallel-cancel: per-cat signal — canceling one cat skips ONLY that cat, not the
      // whole worklist. force-reset/cancelAll aborts every cat's controller, so all entries
      // skip = equivalent to stopping. Using the shared primaryController.signal made
      // "cancel the first cat" break the entire worklist (并发取消误伤根因：serial 路径).
      const catSignal = signalForCat?.(catId) ?? signal;
      if (catSignal?.aborted) {
        index++;
        continue;
      }
      // F148 OQ-2: briefing→invocation link + context eval
      let briefingMessageId: string | undefined;
      let briefingCoverageMap: import('./context-transport.js').CoverageMap | undefined;

      // Only pass images/uploads for the first cat (user's original target)
      const isOriginalTarget = index < targetCats.length;
      const targetContentBlocks = isOriginalTarget ? routeContentBlocksForCat(catId, contentBlocks) : undefined;
      const targetUploadDir = targetContentBlocks ? uploadDir : undefined;

      let prompt = message;
      if (!incrementalMode && previousResponses.length > 0) {
        const contextParts = previousResponses.map((r) => `[${r.catId} responded: ${r.content}]`);
        prompt = `${message}\n\n${contextParts.join('\n')}`;
      }

      // Build identity: static goes in -p content (+ systemPrompt as defense-in-depth), dynamic in -p only
      const catConfig: CatConfig | undefined = catRegistry.tryGet(catId as string)?.config;
      const teammates = [...new Set(worklist.filter((id) => id !== catId))];
      const directMessageFrom = worklistEntry.a2aFrom.get(catId);
      // F167 L1: ping-pong warning — inject when this cat just received the ball
      // in a same-pair streak >= 2 (streak=4 already blocked upstream, so max is 3 here).
      const pingPongWarning =
        worklistEntry.streakPair && worklistEntry.streakPair.to === catId && worklistEntry.streakPair.count >= 2
          ? {
              pairedWith: worklistEntry.streakPair.from,
              count: worklistEntry.streakPair.count,
            }
          : undefined;
      const queueTriggerReplyTo = isOriginalTarget ? a2aTriggerMessageId : undefined;
      const streamReplyTo = worklistEntry.a2aTriggerMessageId.get(catId) ?? queueTriggerReplyTo;
      const streamReplyPreview = streamReplyTo
        ? await hydrateReplyPreview(deps.messageStore, streamReplyTo)
        : undefined;
      // F193 AC-B2: structured cross-thread reply hint hydrated from trigger message.
      // Closes Codex review P1 (砚砚 2026-05-08): worklist `a2aTriggerMessageId` map
      // only has entries for downstream A2A targets — initial target via the modern
      // InvocationQueue path doesn't register in the map. Queue path's trigger id
      // arrives via `routeOptions.currentUserMessageId` (QueueProcessor → routeExecution).
      // Fallback chain ensures queue path also gets the hint without changing
      // streamReplyTo/auto-replyTo behavior (those have different semantics).
      // Same-thread triggers / agent-key path naturally return null inside the helper.
      const crossThreadReplyHintTriggerId = worklistEntry.a2aTriggerMessageId.get(catId) ?? currentUserMessageId;
      const crossThreadReplyHintRaw = crossThreadReplyHintTriggerId
        ? await hydrateCrossThreadReplyHint(deps.messageStore, crossThreadReplyHintTriggerId)
        : null;
      const crossThreadReplyHint = crossThreadReplyHintRaw
        ? {
            sourceThreadId: crossThreadReplyHintRaw.sourceThreadId,
            senderCatId: createCatId(crossThreadReplyHintRaw.senderCatId),
          }
        : undefined;
      let mentionRoutingFeedback = null;
      if (deps.invocationDeps.threadStore) {
        try {
          mentionRoutingFeedback = await deps.invocationDeps.threadStore.consumeMentionRoutingFeedback(threadId, catId);
        } catch (feedbackErr) {
          log.warn({ catId: catId as string, err: feedbackErr }, 'consumeMentionRoutingFeedback failed');
        }
      }
      // mcpAvailable still gates the per-message HTTP callback fallback below
      // (needsMcpInjection). F203 Phase C: the non-pack identity/家规/MCP docs
      // travel via the compression-immune native system role
      // (--system-prompt-file / -c) ONLY for providers that inject L0 natively
      // (ClaudeAgentService -p, ClaudeBgCarrierService, CodexAgent). Other
      // providers (Gemini, Antigravity, CatAgent, A2A, OpenCode, Dare, Kimi…)
      // have no native L0 channel, so they MUST still receive the full static
      // identity via the user-message systemPrompt prepend — otherwise they
      // lose identity/家规 entirely (云端 Codex P1-cloud-1, 2026-05-16).
      const mcpAvailable = (catConfig?.mcpSupport ?? false) && !!mcpServerPath;
      // F129: Load active pack blocks (best-effort, failure does not block invocation)
      let packBlocks: import('@cat-cafe/shared').CompiledPackBlocks | null = null;
      if (deps.packStore) {
        const { getActivePackBlocks } = await import('../../../../packs/getActivePackBlocks.js');
        packBlocks = await getActivePackBlocks(deps.packStore);
      }
      const service = getService(deps.services, catId);
      const hasNativeL0 = service.injectsL0Natively?.() ?? false;
      const staticIdentity = hasNativeL0
        ? buildStaticIdentityPackOnly(catId, { packBlocks })
        : buildStaticIdentity(catId, { mcpAvailable, packBlocks });
      // F041: inject HTTP callback only when MCP is NOT actually available (fallback)
      const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId)
        ? buildMcpCallbackInstructions({
            currentCatId: catId as string,
            teammates: teammates.map((id) => id as string),
          })
        : '';
      // F091: Inject linked signal articles into context
      let activeSignals:
        | readonly {
            id: string;
            title: string;
            source: string;
            tier: number;
            contentSnippet: string;
            note?: string | undefined;
            relatedDiscussions?: readonly { sessionId: string; snippet: string; score: number }[] | undefined;
          }[]
        | undefined;
      if (deps.invocationDeps.signalArticleLookup) {
        try {
          const signals = await deps.invocationDeps.signalArticleLookup(threadId);
          if (signals.length > 0) activeSignals = signals;
        } catch {
          /* best-effort: signal lookup failure does not block invocation */
        }
      }

      // F163 AC-A3: always_on constitutional docs injection (fail-open, flag-gated)
      // shadow: query but do NOT inject into prompt (record-only for experiment diff)
      // on: query AND inject into prompt
      // off: skip entirely
      let alwaysOnDocs: readonly { anchor: string; title: string; summary: string }[] | undefined;
      let alwaysOnInjectionMode: 'off' | 'shadow' | 'on' = 'off';
      if (deps.evidenceStore) {
        try {
          const { freezeFlags } = await import('../../../../../domains/memory/f163-types.js');
          const f163Flags = freezeFlags();
          alwaysOnInjectionMode = f163Flags.alwaysOnInjection;
          if (alwaysOnInjectionMode !== 'off') {
            const queryAlwaysOn = (
              deps.evidenceStore as { queryAlwaysOn?: () => Array<{ anchor: string; title: string; summary: string }> }
            ).queryAlwaysOn;
            if (queryAlwaysOn) {
              const docs = queryAlwaysOn();
              if (docs.length > 0) alwaysOnDocs = docs;
            }
          }
        } catch {
          /* fail-open: always_on lookup failure does not block invocation */
        }
      }

      // F093: Resolve world context for thread (fail-open)
      let worldContext: import('@cat-cafe/shared').WorldContextEnvelope | undefined;
      if (deps.worldStore && deps.worldContextProvider) {
        try {
          const activeWorld = await deps.worldStore.getWorldForThread(threadId);
          if (activeWorld) {
            const scenes = await deps.worldStore.getScenesByWorld(activeWorld.worldId);
            const activeScene = scenes.find((s) => s.status === 'active');
            if (activeScene) {
              const envelope = await deps.worldContextProvider.assemble(activeWorld.worldId, activeScene.sceneId);
              if (envelope) worldContext = envelope;
            }
          }
        } catch {
          /* fail-open: world context lookup failure does not block invocation */
        }
      }

      const invocationMode = worklist.length > 1 ? 'serial' : 'independent';
      const a2aEnabled = worklistEntry.a2aCount < maxDepth;
      const invocationContext = buildInvocationContext({
        catId,
        mode: invocationMode,
        chainIndex: index + 1,
        chainTotal: worklist.length,
        teammates,
        mcpAvailable,
        ...(promptTags && promptTags.length > 0 ? { promptTags } : {}),
        a2aEnabled,
        ...(directMessageFrom ? { directMessageFrom } : {}),
        ...(pingPongWarning ? { pingPongWarning } : {}),
        ...(crossThreadReplyHint ? { crossThreadReplyHint } : {}),
        ...(mentionRoutingFeedback ? { mentionRoutingFeedback } : {}),
        ...(activeParticipants.length > 0 ? { activeParticipants } : {}),
        ...(routingPolicy ? { routingPolicy } : {}),
        ...(sopStageHint ? { sopStageHint } : {}),
        ...(activeSignals ? { activeSignals } : {}),
        ...(voiceMode ? { voiceMode } : {}),
        ...(bootcampState ? { bootcampState, threadId, bootcampMemberCount } : {}),
        ...(alwaysOnDocs && alwaysOnInjectionMode === 'on' ? { alwaysOnDocs } : {}),
        ...guideContextForCat(guideCtx, catId, targetCatIds, threadId),
        ...(worldContext ? { worldContext } : {}),
      });
      const continuityCapsule = buildCapsuleFromRouteState({
        threadId,
        catId: catId as string,
        ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}),
        mode: invocationMode,
        chainIndex: index + 1,
        chainTotal: worklist.length,
        ...(directMessageFrom ? { directMessageFrom: directMessageFrom as string } : {}),
        ...(streamReplyTo ? { a2aTriggerMessageId: streamReplyTo } : {}),
        a2aEnabled,
        a2aDepth: worklistEntry.a2aCount,
        maxA2ADepth: maxDepth,
      });

      // F24 Phase E: Bootstrap context for Session #2+
      let bootstrapContext = '';
      if (
        isSessionChainEnabled(catId) &&
        deps.invocationDeps.sessionChainStore &&
        deps.invocationDeps.transcriptReader
      ) {
        try {
          const bootstrapDepth = getConfigSessionStrategy(catId)?.handoff?.bootstrapDepth;
          const bootstrap = await buildSessionBootstrap(
            {
              sessionChainStore: deps.invocationDeps.sessionChainStore,
              transcriptReader: deps.invocationDeps.transcriptReader,
              ...(deps.invocationDeps.taskStore ? { taskStore: deps.invocationDeps.taskStore } : {}),
              ...(deps.invocationDeps.threadStore ? { threadStore: deps.invocationDeps.threadStore } : {}),
              ...(bootstrapDepth ? { bootstrapDepth } : {}),
            },
            catId,
            threadId,
          );
          if (bootstrap) {
            bootstrapContext = bootstrap.text;
          }
        } catch {
          // Best-effort: bootstrap failure doesn't block invocation
        }
      }

      let deliveryBoundaryId: string | undefined;
      if (incrementalMode) {
        // Serial incremental mode depends on AgentRouter having appended current user message first.
        // We still explicitly include `message` when that message is not present in unseen rows.

        // A+ fix: calculate effective context budget by deducting ALL system parts from maxPromptTokens.
        // Without this, context (up to maxContextTokens=160k) + system parts (~15-20k) can exceed maxPromptTokens.
        const catModePromptForBudget = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt;
        const incBudget = getCatContextBudget(catId as string);
        const incSystemTokens = estimateTokens(
          [staticIdentity, invocationContext, catModePromptForBudget, bootstrapContext, mcpInstructions]
            .filter(Boolean)
            .join('\n'),
        );
        const incMessageTokens = estimateTokens(message);
        const effectiveContextBudget = Math.min(
          Math.max(0, incBudget.maxPromptTokens - incSystemTokens - incMessageTokens - 200),
          incBudget.maxContextTokens,
        );

        const inc = await assembleIncrementalContext(
          deps,
          userId,
          threadId,
          catId,
          currentUserMessageId,
          thinkingMode,
          {
            effectiveMaxContextTokens: effectiveContextBudget,
            canonicalFeatureId: sopStageHint?.featureId,
            threadTitle: routeThread?.title ?? undefined,
          },
        );
        deliveryBoundaryId = inc.boundaryId;
        if (inc.degradation) {
          yield {
            type: 'system_info' as AgentMessageType,
            catId,
            content: inc.degradation,
            timestamp: Date.now(),
          } as AgentMessage;
        }

        // F148 Phase E: Auto-insert context briefing when smart window triggered (AC-E1)
        if (inc.coverageMap) {
          const briefingInput = buildBriefingMessage(inc.coverageMap, threadId, inc.briefingContext);
          try {
            const stored = await deps.messageStore.append(briefingInput);
            briefingMessageId = stored.id;
            briefingCoverageMap = inc.coverageMap;
            // P1-3: Include full stored message in payload so frontend can addMessage directly
            yield {
              type: 'system_info' as AgentMessageType,
              catId,
              content: JSON.stringify({
                type: 'context_briefing',
                messageId: stored.id,
                storedMessage: {
                  id: stored.id,
                  content: stored.content,
                  origin: stored.origin,
                  timestamp: stored.timestamp,
                  extra: stored.extra,
                },
              }),
              timestamp: stored.timestamp,
            } as AgentMessage;
          } catch {
            // fail-open: briefing is non-critical UI enhancement
          }
        }

        const catModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt;
        const parts = [invocationContext, catModePrompt, bootstrapContext, mcpInstructions].filter(Boolean);
        if (inc.contextText) parts.push(inc.contextText);
        // F35 fix: only inject raw message when it was genuinely absent from unseen rows.
        // Defensive guard: if the current message ID is already present anywhere in
        // the assembled context text, do not append the raw message again.
        if (shouldAppendExplicitCurrentMessage(inc, currentUserMessageId)) parts.push(message);
        prompt = parts.join('\n\n---\n\n');
      } else {
        // Per-cat context budget (Phase 4.0): assemble context with cat-specific limits
        let catContextHistory = contextHistory; // fallback to legacy pre-assembled
        if (history && history.length > 0 && !contextHistory) {
          const budget = getCatContextBudget(catId as string);
          // F8: token-based budget — estimate non-context tokens, remainder goes to context
          // A+ fix: include catModePrompt + bootstrapContext in system parts estimate (P2-1)
          const catModePromptLegacyForBudget = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt;
          const systemPartsTokens = estimateTokens(
            [staticIdentity, invocationContext, catModePromptLegacyForBudget, bootstrapContext, mcpInstructions]
              .filter(Boolean)
              .join('\n'),
          );
          const promptTokens = estimateTokens(prompt);
          const budgetForContext = Math.max(0, budget.maxPromptTokens - systemPartsTokens - promptTokens - 200);
          const { contextText, messageCount } = assembleContext(history, {
            maxMessages: budget.maxMessages,
            maxContentLength: budget.maxContentLengthPerMsg,
            maxTotalTokens: Math.min(budgetForContext, budget.maxContextTokens),
          });
          catContextHistory = contextText || undefined;

          // Degradation check: notify user if context was truncated (count budget or char budget)
          const degradation = detectContextDegradation(history.length, messageCount, budget);
          if (degradation?.degraded) {
            yield {
              type: 'system_info' as AgentMessageType,
              catId,
              content: formatDegradationMessage(degradation),
              timestamp: Date.now(),
            } as AgentMessage;
          }
        }

        const catModePromptLegacy = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt;
        if (invocationContext || catModePromptLegacy || mcpInstructions || bootstrapContext) {
          const parts = [invocationContext, catModePromptLegacy, bootstrapContext, mcpInstructions].filter(Boolean);
          if (catContextHistory) parts.push(catContextHistory);
          prompt = `${parts.join('\n\n---\n\n')}\n\n---\n\n${prompt}`;
        } else if (catContextHistory) {
          prompt = `${catContextHistory}\n\n---\n\n${prompt}`;
        }
      }

      let textContent = '';
      const thinkingChunks: string[] = [];
      let firstMetadata: MessageMetadata | undefined;
      let doneMsg: AgentMessage | undefined;
      let hadError = false;
      /** F155: tracks whether cat produced user-visible output (for guide completion ack). */
      let catProducedOutput = false;
      let sawUserFacingSystemInfo = false;
      // #267: track errors that happened BEFORE abort — only these are real provider failures
      let hadProviderError = false;
      // Collect error text separately for system-message persistence (F5 reload)
      let collectedErrorText = '';
      // F212 Phase B (云端 codex P2-8 2026-05-27): persist Phase A's structured
      // cliDiagnostics alongside the error text so cold hydration (F5 reload) can
      // restore the folded panel — without this, only the legacy red-pill survives.
      let collectedCliDiagnostics: import('@cat-cafe/shared').CliDiagnostics | undefined;
      const collectedToolEvents: StoredToolEvent[] = [];
      // F148 OQ-2: Collect tool names for context eval signals
      const collectedToolNames: string[] = [];
      // #573: Track confirmed cat_cafe_post_message callback persistence
      let callbackPostConfirmed = false;
      let callbackPostMessageId: string | undefined;
      let awaitingCallbackResult = false;
      const pendingToolResults: string[] = [];
      const structuredTargetCats = new Set<string>();
      // F060: Collect rich blocks emitted inline via system_info (not MCP buffer)
      const streamRichBlocks: import('@cat-cafe/shared').RichBlock[] = [];
      // F22 R2 P1-1: Capture own invocationId from stream (not getLatestId)
      let ownInvocationId: string | undefined;
      // F111 Phase B: Streaming TTS chunker for real-time voice (voiceMode only)
      let voiceChunker: StreamingTtsChunker | undefined;

      // #80: Draft flush state — periodic persistence for F5 recovery
      let lastFlushTime = Date.now();
      let lastFlushLen = 0;
      let lastFlushToolLen = 0;
      const FLUSH_INTERVAL_MS = 2000;
      const FLUSH_CHAR_DELTA = 2000;
      const noop = () => {};

      // Issue #83: Independent keepalive timer — touch draft every 60s during long tool calls.
      // Stream events alone can't keep draft alive when tools execute silently for >300s.
      const KEEPALIVE_INTERVAL_MS = 60_000;
      let keepaliveTimer: ReturnType<typeof setInterval> | undefined;

      // Always pass isLastCat:false — we set isFinal AFTER A2A detection
      log.debug(
        { catId: catId as string, threadId, promptLength: prompt.length, index, worklistSize: worklist.length },
        'Invoking cat via invokeSingleCat',
      );
      const leakedPayloadStripper = createLeakedToolCallStreamStripper();
      const invocationSpanRef: { current?: Span } = {};
      const invocationStartedAt = Date.now();
      // F215 AC-C3: flag set when invokeSingleCat emits malformed_toolcall_relay_46 signal
      let malformedRelayPending = false;
      for await (const msg of invokeSingleCat(deps.invocationDeps, {
        catId,
        service,
        prompt,
        userId,
        threadId,
        ...(targetContentBlocks ? { contentBlocks: targetContentBlocks } : {}),
        ...(targetUploadDir ? { uploadDir: targetUploadDir } : {}),
        ...(catSignal ? { signal: catSignal } : {}),
        ...(staticIdentity ? { systemPrompt: staticIdentity } : {}),
        ...(options.parentInvocationId ? { parentInvocationId: options.parentInvocationId } : {}),
        continuityCapsule,
        // F121: Pass A2A trigger message ID for auto-replyTo threading
        ...(worklistEntry.a2aTriggerMessageId.get(catId)
          ? { a2aTriggerMessageId: worklistEntry.a2aTriggerMessageId.get(catId) }
          : {}),
        ...((mentionParentSpan.get(index) ?? options.routeSpan)
          ? { routeSpan: mentionParentSpan.get(index) ?? options.routeSpan }
          : {}),
        invocationSpanRef,
        isLastCat: false,
      })) {
        // F39 bugfix: stop yielding after cancel (pipe buffer may still drain)
        if (catSignal?.aborted) break;

        const effectiveMsgs: AgentMessage[] = [];
        if (msg.type === 'text' && msg.content) {
          effectiveMsgs.push({ ...msg, content: leakedPayloadStripper.push(msg.content) });
        } else if (msg.type === 'done') {
          const flushedText = leakedPayloadStripper.flush();
          if (flushedText) {
            effectiveMsgs.push({
              type: 'text',
              catId,
              content: flushedText,
              timestamp: msg.timestamp,
            });
          }
          effectiveMsgs.push(msg);
        } else {
          effectiveMsgs.push(msg);
        }

        for (const effectiveMsg of effectiveMsgs) {
          // F22 R2 P1-1: Capture invocationId from the initial system_info.
          // Keep forwarding this boundary event so frontend can reset stale task progress.
          if (effectiveMsg.type === 'system_info' && effectiveMsg.content && !ownInvocationId) {
            try {
              const parsed = JSON.parse(effectiveMsg.content);
              if (parsed.type === 'invocation_created') {
                ownInvocationId = parsed.invocationId;
                // F111 Phase B: Start streaming TTS when we have an invocationId
                if (voiceMode && deps.socketManager) {
                  const ttsRegistry = getStreamingTtsRegistry();
                  if (ttsRegistry) {
                    voiceChunker = new StreamingTtsChunker({
                      catId: catId as string,
                      invocationId: ownInvocationId!,
                      threadId,
                      voiceConfig: getCatVoice(catId as string),
                      broadcaster: deps.socketManager,
                      ttsRegistry,
                      signal: catSignal,
                    });
                  }
                }
                // Issue #83: Start keepalive timer once we have an invocationId.
                // This ensures draft TTL is renewed even during long silent tool calls.
                if (deps.draftStore && !keepaliveTimer) {
                  const keepInvId = ownInvocationId!;
                  keepaliveTimer = setInterval(() => {
                    deps.draftStore!.touch(userId, threadId, keepInvId)?.catch?.(noop);
                  }, KEEPALIVE_INTERVAL_MS);
                }
              }
            } catch {
              /* ignore parse errors */
            }
          }

          if (effectiveMsg.type === 'text' && effectiveMsg.content) {
            textContent = accumulateTextAggregate(
              textContent,
              effectiveMsg.content,
              (effectiveMsg as { textMode?: 'append' | 'replace' }).textMode,
            );
            voiceChunker?.feed(effectiveMsg.content);
          }
          // F045: Accumulate thinking blocks for persistence (F5 recovery)
          if (effectiveMsg.type === 'system_info' && effectiveMsg.content) {
            if (isUserFacingSystemInfoContent(effectiveMsg.content)) {
              sawUserFacingSystemInfo = true;
            }
            try {
              const parsed = JSON.parse(effectiveMsg.content);
              if (parsed.type === 'thinking' && typeof parsed.text === 'string') {
                thinkingChunks.splice(0, thinkingChunks.length, ...appendThinkingChunk(thinkingChunks, parsed.text));
              }
              // F060: Collect inline rich_block for persistence (P1 fix)
              if (parsed.type === 'rich_block' && parsed.block && isValidRichBlock(parsed.block)) {
                streamRichBlocks.push(parsed.block);
              }
              // F153: Accumulate invocation tokens for route aggregate
              if (parsed.type === 'invocation_usage' && parsed.usage) {
                routeTotalTokens += (parsed.usage.inputTokens ?? 0) + (parsed.usage.outputTokens ?? 0);
              }
              // F215 AC-C3: detect 46-接力 relay signal — set flag to push opus-4.6 after loop.
              // This is an internal routing signal; must be consumed here and NOT yielded to the frontend.
              if (parsed.type === 'malformed_toolcall_relay_46') {
                const relay46CatId = createCatId('opus');
                if (catId !== relay46CatId && Object.hasOwn(deps.services, relay46CatId as string)) {
                  malformedRelayPending = true;
                  log.info(
                    { catId: catId as string, threadId, relay46CatId },
                    '[F215] malformed_toolcall_relay_46 signal received — will push opus-4.6 after loop',
                  );
                }
                continue; // consume routing signal — never surfaces to user as raw JSON
              }
            } catch {
              /* ignore parse errors */
            }
          }
          // F215 AC-C3: suppress malformed error when relay to 46 is already queued
          if (
            malformedRelayPending &&
            effectiveMsg.type === 'error' &&
            typeof effectiveMsg.error === 'string' &&
            effectiveMsg.error.startsWith('malformed_toolcall:')
          ) {
            continue; // 46 will take over — don't surface error to user
          }
          // Accumulate tool events for persistence (before draft flush so current event is available)
          const toolEvt = toStoredToolEvent(effectiveMsg);
          if (toolEvt) {
            collectedToolEvents.push(toolEvt);
          }

          if (effectiveMsg.type === 'tool_use') {
            for (const target of collectStructuredTargetCatsFromInput(effectiveMsg.toolInput)) {
              structuredTargetCats.add(target);
            }
          }

          // F148 OQ-2: Collect tool names for context eval
          if (effectiveMsg.type === 'tool_use' && effectiveMsg.toolName) {
            collectedToolNames.push(effectiveMsg.toolName);
            pendingToolResults.push(effectiveMsg.toolName);
            if (isPostMessageToolName(effectiveMsg.toolName)) awaitingCallbackResult = true;
          }
          // #573: Confirm callback persistence via tool_result success
          if (effectiveMsg.type === 'tool_result') {
            const callbackResult = parseCallbackPostResult(effectiveMsg.content);
            const completedToolName = consumePendingToolResult(
              pendingToolResults,
              effectiveMsg,
              callbackResult.confirmed,
              Boolean(callbackResult.messageId && callbackResult.threadId),
            );
            if (
              awaitingCallbackResult &&
              completedToolName &&
              isPostMessageToolName(completedToolName) &&
              callbackResult.confirmed
            ) {
              callbackPostConfirmed = true;
              awaitingCallbackResult = false;
              if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId;
            }
            // F188 Phase F AC-F10 (砚砚 六审 P1-B: also scope by catId for serial route consistency).
            // 砚砚 cloud-3 P1: also pass toolUseId for exact match when available;
            // otherwise FIFO toolName+catId match handles same-name parallel calls.
            if (deps.toolEventLog && completedToolName) {
              const normalizedName = normalizeMcpToolName(completedToolName);
              const resultSummary = deriveResultSummary(normalizedName, effectiveMsg.content);
              if (Object.keys(resultSummary).length > 0) {
                const resultMsg = effectiveMsg as { catId?: string; toolUseId?: string };
                const matcher: { toolUseId?: string; toolName?: string; catId?: string } = resultMsg.toolUseId
                  ? { toolUseId: resultMsg.toolUseId }
                  : resultMsg.catId
                    ? { toolName: normalizedName, catId: resultMsg.catId }
                    : { toolName: normalizedName };
                deps.toolEventLog.updateSummary(threadId, matcher, resultSummary).catch(() => {});
              }
            }
          }

          // F150: Fire-and-forget tool usage counter
          if (effectiveMsg.type === 'tool_use' && deps.toolUsageCounter && effectiveMsg.catId) {
            deps.toolUsageCounter.recordToolUse(
              effectiveMsg.catId as string,
              effectiveMsg.toolName ?? 'unknown',
              effectiveMsg.toolInput as Record<string, unknown> | undefined,
            );
          }
          // F188 Phase F AC-F10: append-only tool event log (砚砚 三审 P1 wiring)
          if (effectiveMsg.type === 'tool_use' && deps.toolEventLog && effectiveMsg.catId) {
            const msg = effectiveMsg as {
              catId?: string;
              toolName?: string;
              toolInput?: Record<string, unknown>;
              toolUseId?: string;
              invocationId?: string;
              sessionId?: string;
              threadId?: string;
              turnIndex?: number;
            };
            // 砚砚 四审 P1-1: normalizeMcpToolName handles mcp__/mcp:/cat_cafe_ child extraction
            const rawToolName = msg.toolName ?? 'unknown';
            const classification = classifyTool(rawToolName, msg.toolInput);
            const normalizedToolName =
              classification.category === 'skill' ? classification.toolName : normalizeMcpToolName(rawToolName);
            // 砚砚 cloud-3 P1: propagate toolUseId into summary (as _toolUseId) so
            // updateSummary can do exact match when provider emits it on tool_result.
            const baseSummary = (msg.toolInput ?? {}) as Record<string, unknown>;
            const summary: Record<string, unknown> = msg.toolUseId
              ? { ...baseSummary, _toolUseId: msg.toolUseId }
              : baseSummary;
            deps.toolEventLog
              .append({
                invocationId: msg.invocationId ?? ownInvocationId ?? 'unknown',
                sessionId: msg.sessionId ?? ownInvocationId ?? 'unknown',
                threadId: msg.threadId ?? threadId ?? 'unknown',
                catId: msg.catId ?? 'unknown',
                toolName: normalizedToolName,
                timestamp: Date.now(),
                turnIndex: msg.turnIndex ?? 0,
                status: 'success',
                summary,
              })
              .catch(() => {});
            // 砚砚 二审 P1-4: detect Skill tool_use → SkillLoadEventLog (AS-4 producer path)
            if (rawToolName === 'Skill' && deps.skillLoadEventLog) {
              const skillName =
                msg.toolInput && typeof msg.toolInput['skill'] === 'string'
                  ? (msg.toolInput['skill'] as string)
                  : 'unknown';
              deps.skillLoadEventLog
                .append({
                  invocationId: msg.invocationId ?? ownInvocationId ?? 'unknown',
                  sessionId: msg.sessionId ?? ownInvocationId ?? 'unknown',
                  skillId: skillName,
                  loadTrigger: 'explicit_call',
                  timestamp: Date.now(),
                })
                .catch(() => {});
            }
          }

          // #80: Draft flush — fire-and-forget periodic persistence for F5 recovery
          if (deps.draftStore && ownInvocationId) {
            const now = Date.now();
            const charDelta = textContent.length - lastFlushLen;
            const isReplaceText = (effectiveMsg as { textMode?: 'append' | 'replace' }).textMode === 'replace';
            const neverFlushed = lastFlushLen === 0 && lastFlushToolLen === 0;
            if (
              effectiveMsg.type === 'text' &&
              charDelta !== 0 &&
              (neverFlushed ||
                isReplaceText ||
                now - lastFlushTime >= FLUSH_INTERVAL_MS ||
                charDelta >= FLUSH_CHAR_DELTA)
            ) {
              deps.draftStore
                .upsert({
                  userId,
                  threadId,
                  invocationId: ownInvocationId,
                  catId,
                  content: textContent,
                  ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}),
                  ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}),
                  updatedAt: now,
                })
                ?.catch?.(noop);
              lastFlushTime = now;
              lastFlushLen = textContent.length;
              lastFlushToolLen = collectedToolEvents.length;
            } else if (
              (effectiveMsg.type === 'tool_use' || effectiveMsg.type === 'tool_result') &&
              // Cloud R7 P1: bypass interval for the very first flush — tool-first invocations
              // must create a draft immediately, not wait 2s for the interval gate.
              (neverFlushed || now - lastFlushTime >= FLUSH_INTERVAL_MS)
            ) {
              // Heartbeat for non-text events: keep draft alive during long tool calls.
              // Cloud R6 P1: upsert when there's unsaved text OR new tool events —
              // tool-first invocations (no text yet) must still create a draft record.
              if (textContent.length > lastFlushLen || collectedToolEvents.length > lastFlushToolLen) {
                deps.draftStore
                  .upsert({
                    userId,
                    threadId,
                    invocationId: ownInvocationId,
                    catId,
                    content: textContent,
                    ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}),
                    ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}),
                    updatedAt: now,
                  })
                  ?.catch?.(noop);
                lastFlushLen = textContent.length;
                lastFlushToolLen = collectedToolEvents.length;
              } else {
                deps.draftStore.touch(userId, threadId, ownInvocationId)?.catch?.(noop);
              }
              lastFlushTime = now;
            }
          }

          if (effectiveMsg.type === 'error') {
            hadError = true;
            // #267: errors before abort are real provider failures; errors after abort are cleanup
            if (!catSignal?.aborted) hadProviderError = true;
            if (effectiveMsg.error) {
              collectedErrorText += `${collectedErrorText ? '\n' : ''}${effectiveMsg.error}`;
            }
            // F212 Phase B (云端 codex P2-8): capture structured cliDiagnostics from
            // metadata; keep the first one seen (canonical for this invocation).
            const meta = effectiveMsg.metadata as
              | { cliDiagnostics?: import('@cat-cafe/shared').CliDiagnostics }
              | undefined;
            if (meta?.cliDiagnostics && !collectedCliDiagnostics) {
              collectedCliDiagnostics = meta.cliDiagnostics;
            }
          }
          if (effectiveMsg.metadata && !firstMetadata) {
            firstMetadata = effectiveMsg.metadata;
          }
          if (effectiveMsg.type === 'done') {
            doneMsg = effectiveMsg; // Buffer — yield after A2A detection
          } else {
            if (effectiveMsg.type === 'text' && !effectiveMsg.content) {
              continue;
            }
            // F194 Phase Z9 砚砚 R1 P1-1: stamp ownInvocationId on yielded stream events
            // so downstream broadcaster (messages.ts) doesn't fall back to parent when
            // assigning turnInvocationId. CLI text/done/tool events don't carry
            // invocationId; only system_info=invocation_created does. Without explicit
            // stamping, multi-turn same-cat under shared parent collapses to one bubble.
            const ownStampedMsg =
              ownInvocationId && !effectiveMsg.invocationId
                ? { ...effectiveMsg, invocationId: ownInvocationId }
                : effectiveMsg;
            // Tag CLI stdout text with origin: 'stream' (thinking/internal)
            yield ownStampedMsg.type === 'text'
              ? {
                  ...ownStampedMsg,
                  origin: 'stream' as const,
                  ...(streamReplyTo ? { replyTo: streamReplyTo } : {}),
                  ...(streamReplyPreview ? { replyPreview: streamReplyPreview } : {}),
                }
              : ownStampedMsg;
          }
        }
      }

      // Issue #83: Stop keepalive timer — streaming loop has exited.
      if (keepaliveTimer) {
        clearInterval(keepaliveTimer);
        keepaliveTimer = undefined;
      }

      // F215 AC-C3: push opus-4.6 to worklist as relay when 48 炸毛 + fresh retry also failed
      if (malformedRelayPending) {
        const relay46CatId = createCatId('opus');
        if (
          catId !== relay46CatId &&
          Object.hasOwn(deps.services, relay46CatId as string) &&
          // P2 fix + P1 #1 fix: only check PENDING entries (worklist[index+1..]) not the full
          // worklist. worklist[0..index] are already executed; including them would silently skip
          // a legitimate relay when opus ran first in the route (e.g. [opus, opus-48]).
          !worklist.slice(index + 1).includes(relay46CatId)
        ) {
          worklist.push(relay46CatId);
          worklistEntry.a2aCount++;
          worklistEntry.a2aFrom.set(relay46CatId, catId);
          log.info(
            { catId: catId as string, relay46CatId, threadId, a2aCount: worklistEntry.a2aCount },
            '[F215] Pushed opus-4.6 to worklist for malformed tool-call relay (AC-C3)',
          );
        } else if (worklist.slice(index + 1).includes(relay46CatId)) {
          log.info(
            { catId: catId as string, relay46CatId, threadId },
            '[F215] opus-4.6 already pending in worklist — skipping duplicate relay push (P2 dedup)',
          );
        }
        malformedRelayPending = false;
      }

      // F111 Phase B: Flush remaining buffered text and send voice_stream_end
      let voiceTotalChunks = 0;
      if (voiceChunker) {
        try {
          voiceTotalChunks = await voiceChunker.flush();
        } catch (err) {
          log.error({ err }, 'Voice chunker flush failed');
        }
        if (deps.socketManager && voiceChunker.hasStarted()) {
          const aborted = catSignal?.aborted ?? false;
          deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'voice_stream_end', {
            type: 'voice_stream_end',
            catId: catId as string,
            invocationId: ownInvocationId ?? '',
            threadId,
            totalChunks: aborted ? -1 : voiceTotalChunks,
          });
        }
        voiceChunker = undefined;
      }

      let a2aMentions: CatId[] = [];

      // F22: Consume MCP-buffered rich blocks BEFORE the text/empty branch —
      // blocks must be persisted even when the cat emits no text (cloud Codex P1).
      const bufferedBlocks = getRichBlockBuffer().consume(threadId, catId as string, ownInvocationId);

      // F061: Detect @co-creator mentions in agent response for browser notification
      let mentionsUser = false;

      if (textContent) {
        catProducedOutput = true;
        const sanitized = sanitizeInjectedContent(textContent);

        // F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats)
        const { cleanText, blocks: textBlocks } = extractRichFromText(sanitized);
        const storedContent = cleanText;
        let allRichBlocks = [...bufferedBlocks, ...textBlocks, ...streamRichBlocks];

        // F34-b: Resolve voice blocks (audio with text, no url) — Route B path.
        // Route A blocks were already resolved in the callback handler.
        // F111: When voiceMode is active, skip full synthesis so audio blocks
        // arrive at the frontend with text but no url — the frontend will use
        // /api/tts/stream for chunked streaming playback (<2s first-audio).
        if (!voiceMode) {
          const voiceSynth = getVoiceBlockSynthesizer();
          if (voiceSynth && allRichBlocks.some((b) => b.kind === 'audio' && 'text' in b)) {
            try {
              allRichBlocks = await voiceSynth.resolveVoiceBlocks(allRichBlocks, catId as string);
            } catch (err) {
              log.error({ catId: catId as string, err }, 'Voice block synthesis failed');
            }
          }
        }

        // In play mode, CLI stream output (thinking) is hidden from other cats.
        // Only share previousResponses in debug mode where cats see each other's thinking.
        // Important: push after review gate mutation so downstream cats see invalid-review marker.
        if (!incrementalMode && thinkingMode === 'debug') {
          previousResponses.push({ catId, content: storedContent });
        }

        // A2A mention detection (缅因猫 P1-3: only after full text accumulated)
        // Line-start @mention = always actionable (no keyword gate)
        a2aMentions = parseA2AMentions(storedContent, catId);

        // clowder-ai#489: baseline counter — line-start mentions
        if (a2aMentions.length > 0) {
          lineStartDetected.add(a2aMentions.length, { 'agent.id': catId as string });
        }

        // F167 Phase H AC-H3/H5 (KD-24): final routing slot validator.
        // Mechanical slot check with zero intent classifier. Runs BEFORE #417
        // inline-mention-hint and AC-C7 verdict warn; hit suppresses the system_info
        // emit on both (but keeps setMentionRoutingFeedback for next-turn correction).
        const phaseHRosterHandles: string[] = [];
        {
          const allCfg = catRegistry.getAllConfigs();
          for (const cfg of Object.values(allCfg) as CatConfig[]) {
            for (const pattern of cfg.mentionPatterns) phaseHRosterHandles.push(pattern);
          }
        }
        const phaseHResult = validateRoutingSyntax({
          text: storedContent,
          lineStartMentions: a2aMentions,
          toolNames: collectedToolNames,
          structuredTargetCats: [...structuredTargetCats],
          rosterHandles: phaseHRosterHandles,
        });
        const phaseHHit = phaseHResult.kind === 'invalid_route_syntax';
        if (phaseHHit && phaseHResult.kind === 'invalid_route_syntax') {
          try {
            const inlineList = phaseHResult.inlineMentions.map((h) => `@${h}`).join(' ');
            const hintSource = {
              connector: 'routing-syntax-hint',
              label: '路由语法提醒',
              icon: '⚠️',
              meta: { presentation: 'system_notice', noticeTone: 'warning' },
            };
            const stored = await deps.messageStore.append({
              userId: 'system',
              catId: null,
              threadId,
              content: `[路由语法]: ${inlineList} 写在行中不会触发路由 — 把 @句柄 移到最后一行行首独立一行即可。`,
              mentions: [],
              timestamp: Date.now(),
              source: hintSource,
            });
            if (deps.socketManager) {
              deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', {
                threadId,
                message: {
                  id: stored.id,
                  type: 'connector',
                  content: stored.content,
                  source: hintSource,
                  timestamp: stored.timestamp,
                },
              });
            }
          } catch {
            /* non-blocking hint */
          }
        }

        // #417 / F064 AC-B3: Write-side feedback for inline action-like @mentions
        // clowder-ai#489: counters for detection, shadow, feedback, hint
        if (deps.invocationDeps.threadStore) {
          const {
            strictHits: inlineHits,
            shadowMisses,
            routedSetSkips,
          } = detectInlineActionMentionsWithShadow(storedContent, catId, a2aMentions);
          const agentAttr = { 'agent.id': catId as string };
          inlineActionChecked.add(1, agentAttr);
          if (inlineHits.length > 0) inlineActionDetected.add(inlineHits.length, agentAttr);
          if (shadowMisses.length > 0) inlineActionShadowMiss.add(shadowMisses.length, agentAttr);
          if (routedSetSkips > 0) inlineActionRoutedSetSkip.add(routedSetSkips, agentAttr);

          if (inlineHits.length > 0) {
            try {
              await deps.invocationDeps.threadStore.setMentionRoutingFeedback(threadId, catId, {
                sourceTimestamp: Date.now(),
                items: inlineHits.map((m) => ({ targetCatId: m.catId, reason: 'inline_action' as const })),
              });
              inlineActionFeedbackWritten.add(1, agentAttr);
              log.info(
                { catId: catId as string, threadId, targets: inlineHits.map((h) => h.catId) },
                'Inline action @mention detected — wrote routing feedback',
              );
            } catch {
              inlineActionFeedbackWriteFailed.add(1, agentAttr);
            }
            // #1062: User-visible system message when chain would break
            // (inline action detected but no line-start @ = no routing will happen)
            // F167 Phase H AC-H5: suppress this legacy hint when Phase H already emitted
            // routing-syntax-hint for the same turn (dedupe, single authoritative message).
            if (a2aMentions.length === 0 && !phaseHHit) {
              try {
                const targets = inlineHits.map((h) => `@${h.catId}`).join(', ');
                const hintSource = {
                  connector: 'inline-mention-hint',
                  label: '路由提示',
                  icon: '💡',
                  meta: { presentation: 'system_notice', noticeTone: 'info' },
                };
                const stored = await deps.messageStore.append({
                  userId: 'system',
                  catId: null,
                  threadId,
                  content: `想交接给 ${targets}？把它单独放到新起一行开头，才能触发交接。`,
                  mentions: [],
                  timestamp: Date.now(),
                  source: hintSource,
                });
                inlineActionHintEmitted.add(1, agentAttr);
                // Broadcast so frontend sees it in real-time (same pattern as vote result)
                if (deps.socketManager) {
                  deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', {
                    threadId,
                    message: {
                      id: stored.id,
                      type: 'connector',
                      content: stored.content,
                      source: hintSource,
                      timestamp: stored.timestamp,
                    },
                  });
                }
              } catch {
                inlineActionHintEmitFailed.add(1, agentAttr);
              }
            }
          }
        }

        // F167 Phase H AC-H5: suppress AC-C7 verdict-without-pass when Phase H hit
        // (format error is the root cause; verdict-without-pass is the consequence).
        // 2026-04-25 fix (砚砚 GPT-5.5): pass hasCoCreatorLineStartMention so summary
        // reports ending with `@co-creator` / `@铲屎官` (legitimate escalation to co-creator)
        // don't trigger the verdict-no-pass-hint false-positive. parseA2AMentions only
        // returns cat handles, never co-creator ones.
        //
        // C2 denominator (F192 2026-05-29): count every turn the verdict-without-pass
        // exit-check actually evaluates, so attribution can grade verdict_without_pass_count
        // against a real `c2.checked` base instead of fabricating a 100% ratio. phaseHHit
        // turns are excluded — a format error short-circuits the check (AC-H5), so they
        // were never evaluated.
        if (!phaseHHit) {
          c2ExitChecked.add(1, { 'agent.id': catId as string });
        }
        if (
          !phaseHHit &&
          shouldWarnVerdictWithoutPass({
            text: storedContent,
            lineStartMentions: a2aMentions,
            toolNames: collectedToolNames,
            structuredTargetCats: [...structuredTargetCats],
            hasCoCreatorLineStartMention: storedContent ? detectUserMention(storedContent) : false,
          })
        ) {
          try {
            const hintSource = {
              connector: 'verdict-no-pass-hint',
              label: '球权提醒',
              icon: '🏓',
              meta: { presentation: 'system_notice', noticeTone: 'warning' },
            };
            const stored = await deps.messageStore.append({
              userId: 'system',
              catId: null,
              threadId,
              content: '[球权提醒]: 结论后直接传球，不要停在结论 — 末尾加一行行首 @句柄 或调用 `cat_cafe_hold_ball`。',
              mentions: [],
              timestamp: Date.now(),
              source: hintSource,
            });
            c2VerdictHintEmitted.add(1, { 'agent.id': catId as string });
            c2VerdictWithoutPassCount.add(1, { 'agent.id': catId as string });
            if (deps.socketManager) {
              deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', {
                threadId,
                message: {
                  id: stored.id,
                  type: 'connector',
                  content: stored.content,
                  source: hintSource,
                  timestamp: stored.timestamp,
                },
              });
            }
          } catch {
            /* non-blocking hint */
          }
        }

        // F167 Phase I AC-I1 (KD-25): void hold detection — text says "持球" but
        // no cat_cafe_hold_ball tool call this turn.声明-动作一致性 check.
        // C2 void-hold denominator (PR #1941 P2): count every void-hold evaluation so
        // attribution grades void_hold_hint against c2.void_hold_checked, NOT the
        // verdict-check count c2.checked (different guard → wrong ratio / suppression).
        c2VoidHoldChecked.add(1, { 'agent.id': catId as string });
        if (
          shouldWarnVoidHold({
            text: storedContent,
            toolNames: collectedToolNames,
            lineStartMentions: a2aMentions,
            structuredTargetCats: [...structuredTargetCats],
            hasCoCreatorLineStartMention: storedContent ? detectUserMention(storedContent) : false,
          })
        ) {
          try {
            const hintSource = {
              connector: 'void-hold-hint',
              label: '持球提醒',
              icon: '🏓',
              meta: { presentation: 'system_notice', noticeTone: 'warning' },
            };
            const voidStored = await deps.messageStore.append({
              userId: 'system',
              catId: null,
              threadId,
              content:
                '[持球提醒]: 检测到持球声明但未调用 hold_ball MCP — ' +
                '文字声明不会设定唤醒计时器，请调用 `cat_cafe_hold_ball` 完成持球或改为传球。',
              mentions: [],
              timestamp: Date.now(),
              source: hintSource,
            });
            c2VoidHoldHintEmitted.add(1, { 'agent.id': catId as string });
            if (deps.socketManager) {
              deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', {
                threadId,
                message: {
                  id: voidStored.id,
                  type: 'connector',
                  content: voidStored.content,
                  source: hintSource,
                  timestamp: voidStored.timestamp,
                },
              });
            }
          } catch {
            /* non-blocking hint */
          }
        }

        // F079 Phase 2: Vote interception — extract [VOTE:xxx] from cat response
        const votedOption = extractVoteFromText(storedContent);
        if (votedOption && deps.invocationDeps.threadStore) {
          try {
            const voteState = await deps.invocationDeps.threadStore.getVotingState(threadId);
            if (voteState && voteState.status === 'active' && voteState.options.includes(votedOption)) {
              // Deadline enforcement (parity with HTTP cast path)
              if (Date.now() > voteState.deadline) {
                log.info({ threadId, votedOption }, 'Vote expired, ignoring');
              } else if (
                voteState.voters &&
                voteState.voters.length > 0 &&
                !voteState.voters.includes(catId as string) &&
                (catId as string) !== voteState.initiatedByCat
              ) {
                log.info({ catId: catId as string, threadId }, 'Not in voters list, ignoring vote');
              } else {
                voteState.votes[catId as string] = votedOption;
                await deps.invocationDeps.threadStore.updateVotingState(threadId, voteState);
                log.info({ catId: catId as string, votedOption, threadId }, 'Vote cast');

                // Auto-close if all designated voters have voted
                if (checkVoteCompletion(voteState)) {
                  const tally = buildVoteTally(voteState.options, voteState.votes);
                  const totalVotes = Object.values(voteState.votes).length;
                  const fields = voteState.options.map((opt) => ({
                    label: opt,
                    value: `${tally[opt] ?? 0} 票 (${totalVotes > 0 ? Math.round(((tally[opt] ?? 0) / totalVotes) * 100) : 0}%)`,
                  }));
                  const richBlock = {
                    id: `vote-${Date.now()}`,
                    kind: 'card' as const,
                    v: 1 as const,
                    title: `投票结果: ${voteState.question}`,
                    bodyMarkdown: voteState.anonymous ? `匿名投票 · ${totalVotes} 票` : `实名投票 · ${totalVotes} 票`,
                    tone: 'info' as const,
                    fields,
                  };
                  await deps.invocationDeps.threadStore.updateVotingState(threadId, null);
                  // F079 Bug 1 fix: do NOT push richBlock into allRichBlocks — that
                  // embeds the result in the cat's own message, causing duplication.
                  // Only the standalone connector message below should carry the result.
                  // Gap 3: persist separate connector message for ConnectorBubble rendering
                  try {
                    const stored = await deps.messageStore.append({
                      userId,
                      catId: null,
                      content: `投票结果: ${voteState.question}`,
                      mentions: [],
                      timestamp: Date.now(),
                      threadId,
                      source: VOTE_RESULT_SOURCE,
                      extra: { rich: { v: 1 as const, blocks: [richBlock] } },
                    });
                    // F079 Bug 2 fix: broadcast connector_message so frontend updates without F5
                    if (deps.socketManager) {
                      deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', {
                        threadId,
                        message: {
                          id: stored.id,
                          type: 'connector',
                          content: stored.content,
                          source: VOTE_RESULT_SOURCE,
                          timestamp: stored.timestamp,
                          extra: stored.extra,
                        },
                      });
                    }
                  } catch (persistErr) {
                    log.warn({ threadId, err: persistErr }, 'Failed to persist vote connector message');
                  }
                  log.info({ threadId }, 'Vote auto-closed');
                }
              }
            }
          } catch (voteErr) {
            log.warn({ catId: catId as string, err: voteErr }, 'Vote interception failed');
          }
        }

        const storedTimestamp = invocationStartedAt;

        // F061: Detect @co-creator mentions in agent response for browser notification
        mentionsUser = storedContent ? detectUserMention(storedContent) : false;

        // #573: skip stream store only when callback confirmed persistence (not just invocation)
        const callbackAlreadyStored = callbackPostConfirmed;

        // Store with actual mentions — degrade on failure to ensure done reaches frontend
        // (缅因猫 review P1-2: Redis failure must not block done yield)
        let storedMsgId: string | undefined;
        try {
          // #573: persist with the OUTER cat-cafe parentInvocationId (set by QueueProcessor)
          const persistedInvocationId = options.parentInvocationId ?? ownInvocationId;
          if (!callbackAlreadyStored) {
            const storedMsg = await deps.messageStore.append({
              userId,
              catId,
              content: storedContent,
              mentions: a2aMentions,
              origin: 'stream',
              timestamp: storedTimestamp,
              threadId,
              ...(mentionsUser ? { mentionsUser } : {}),
              ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}),
              ...(firstMetadata ? { metadata: firstMetadata } : {}),
              ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}),
              ...(streamReplyTo ? { replyTo: streamReplyTo } : {}),
              extra: {
                ...(allRichBlocks.length > 0 ? { rich: { v: 1 as const, blocks: allRichBlocks } } : {}),
                // F194 Phase Z3: dual id — invocationId=parent (legacy SoT for liveness/queue/cancel),
                // turnInvocationId=own (Z3 new SoT for frontend bubble identity stable key, prevents
                // same-parent multi-turn-same-cat bubble merge).
                // F194 Phase Z9 AC-Z25 (KD-28): always stamp turnInvocationId.
                // First-in-chain (ownInvocationId === parent) still gets explicit
                // turn stamp so frontend bubble identity never falls back to parent
                // (which would let multi-turn same-cat under same parent collapse).
                ...(persistedInvocationId
                  ? {
                      stream: {
                        invocationId: persistedInvocationId,
                        turnInvocationId: ownInvocationId ?? persistedInvocationId,
                      },
                    }
                  : {}),
                ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}),
              },
            });
            storedMsgId = storedMsg.id;
            // F088-P3: Stash rich blocks for outbound delivery
            if (options.persistenceContext && allRichBlocks.length > 0) {
              options.persistenceContext.richBlocks = allRichBlocks;
            }
          } else {
            log.info(
              { threadId, catId: catId as string, callbackMessageId: callbackPostMessageId },
              'Stream store skipped — cat_cafe_post_message callback already persisted',
            );
            if (callbackPostMessageId) {
              const metadataPatch: StreamMetadataAugmentInput = {
                ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}),
                ...(firstMetadata ? { metadata: firstMetadata } : {}),
                ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}),
                ...(streamReplyTo ? { replyTo: streamReplyTo } : {}),
                ...(mentionsUser ? { mentionsUser } : {}),
              };
              const extraParts = {
                ...(allRichBlocks.length > 0 ? { rich: { v: 1 as const, blocks: allRichBlocks } } : {}),
                // F194 Phase Z3: dual id — invocationId=parent (legacy SoT for liveness/queue/cancel),
                // turnInvocationId=own (Z3 new SoT for frontend bubble identity stable key, prevents
                // same-parent multi-turn-same-cat bubble merge).
                // F194 Phase Z9 AC-Z25 (KD-28): always stamp turnInvocationId.
                // First-in-chain (ownInvocationId === parent) still gets explicit
                // turn stamp so frontend bubble identity never falls back to parent
                // (which would let multi-turn same-cat under same parent collapse).
                ...(persistedInvocationId
                  ? {
                      stream: {
                        invocationId: persistedInvocationId,
                        turnInvocationId: ownInvocationId ?? persistedInvocationId,
                      },
                    }
                  : {}),
                ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}),
              };
              if (Object.keys(extraParts).length > 0) metadataPatch.extra = extraParts;

              if (hasStreamMetadataPatch(metadataPatch)) {
                try {
                  const augmented = await deps.messageStore.augmentStreamMetadata(callbackPostMessageId, metadataPatch);
                  if (!augmented) {
                    log.warn(
                      { threadId, catId: catId as string, callbackMessageId: callbackPostMessageId },
                      'Callback message metadata augment skipped: message not found',
                    );
                  }
                } catch (augmentErr) {
                  log.warn(
                    { threadId, catId: catId as string, callbackMessageId: callbackPostMessageId, err: augmentErr },
                    'Callback message metadata augment failed; continuing without duplicate stream append',
                  );
                }
              }
            }
          }
          // #80: Clean up draft after message is persisted (either via append or callback)
          if (deps.draftStore && ownInvocationId) {
            deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
          }
          // Cloud Codex R4 P1 fix: Update activity in isolated try/catch to not affect append status
          if (deps.invocationDeps.threadStore) {
            try {
              await deps.invocationDeps.threadStore.updateParticipantActivity(
                threadId,
                catId,
                // #267: only errors before abort are provider failures
                !hadProviderError,
              );
            } catch (activityErr) {
              log.warn({ catId: catId as string, err: activityErr }, 'updateParticipantActivity failed');
            }
          }
        } catch (err) {
          log.error({ catId: catId as string, err }, 'messageStore.append failed, degrading');
          if (options.persistenceContext) {
            options.persistenceContext.failed = true;
            options.persistenceContext.errors.push({
              catId: catId as string,
              error: err instanceof Error ? err.message : String(err),
            });
          }
        }

        if (invocationSpanRef.current) catInvocationSpans.set(index, invocationSpanRef.current);

        // A2A: extend worklist if mention found + depth allows + queue fairness gate
        // F27: dedup only against pending (not-yet-executed) tail — cats that already ran
        // can be re-enqueued for another round (e.g. A→B→A review ping-pong).
        let queuedMessagesPending = false;
        if (queueHasQueuedMessages) {
          try {
            queuedMessagesPending = queueHasQueuedMessages(threadId);
          } catch {
            queuedMessagesPending = false;
          }
        }

        // Diagnostic: log when A2A text-scan gate blocks
        if (a2aMentions.length > 0) {
          if (queuedMessagesPending) {
            log.info(
              { threadId, catId, a2aMentions, a2aCount: worklistEntry.a2aCount },
              'A2A text-scan blocked: non-agent messages pending in queue (fairness gate)',
            );
          } else if (worklistEntry.a2aCount >= maxDepth) {
            log.info(
              { threadId, catId, a2aMentions, a2aCount: worklistEntry.a2aCount, maxDepth },
              'A2A text-scan blocked: depth limit reached',
            );
          } else if (catSignal?.aborted) {
            log.info({ threadId, catId, a2aMentions }, 'A2A text-scan blocked: signal aborted');
          }
        }

        if (
          a2aMentions.length > 0 &&
          worklistEntry.a2aCount < maxDepth &&
          !catSignal?.aborted &&
          !queuedMessagesPending
        ) {
          // F153: mention_dispatch span — tracks the causal link between mentioner and dispatched targets
          let dispatchSpan: Span | undefined;
          const pendingTail = worklist.slice(index + 1);
          const pendingOriginalTargets = targetCats.slice(index + 1);
          for (const nextCat of a2aMentions) {
            if (worklistEntry.a2aCount >= maxDepth) break;
            // A2A cross-path dedup: skip if this cat is actively processing via callback (InvocationQueue)
            if (hasQueuedOrActiveAgentForCat && hasQueuedOrActiveAgentForCat(threadId, nextCat)) {
              log.info(
                { threadId, catId: nextCat, fromCat: catId },
                'A2A text-scan dedup: cat actively processing in InvocationQueue, skipping',
              );
              continue;
            }
            if (pendingTail.includes(nextCat)) {
              // Keep original user-selected targets replying to user, not to another cat.
              if (!pendingOriginalTargets.includes(nextCat)) {
                worklistEntry.a2aFrom.set(nextCat, catId);
                // F121: response-text path — set trigger message for auto-replyTo
                if (storedMsgId) worklistEntry.a2aTriggerMessageId.set(nextCat, storedMsgId);
              }
              continue;
            }
            // F167 L1 + Phase D: ping-pong streak check (canonical enqueue point).
            // callerActivity (substantive tool + output length) gates streak accumulation —
            // real work / long discussion no longer trips the breaker falsely.
            // streak=4+ (pure language inertia) → block enqueue + emit a2a_pingpong_terminated.
            const hadSubstantiveToolCall = collectedToolNames.some((n) => isSubstantiveTool(n));
            const streak = updateStreakOnPush(worklistEntry, catId, nextCat, {
              hadSubstantiveToolCall,
              outputLength: storedContent.length,
            });
            if (streak.blockPingPong) {
              log.info(
                { threadId, catId: nextCat, fromCat: catId, count: streak.count },
                'F167 L1: A2A ping-pong terminated (streak >= 4)',
              );
              yield {
                type: 'system_info' as AgentMessageType,
                catId,
                content: JSON.stringify({
                  type: 'a2a_pingpong_terminated',
                  fromCatId: catId,
                  targetCatId: nextCat,
                  pairCount: streak.count,
                }),
                timestamp: Date.now(),
              } as AgentMessage;
              continue;
            }

            // F153: lazily create mention_dispatch span on first actual push
            if (!dispatchSpan) {
              const mentionerSpan = catInvocationSpans.get(index);
              if (mentionerSpan) {
                const parentCtx = trace.setSpan(context.active(), mentionerSpan);
                dispatchSpan = routeSerialTracer.startSpan(
                  'cat_cafe.mention_dispatch',
                  {
                    attributes: { [AGENT_ID]: catId as string, 'dispatch.target_count': a2aMentions.length },
                  },
                  parentCtx,
                );
                // F153 Phase I: counter for Step Summary aggregate; only AGENT_ID attribute (mentioner cat).
                a2aDispatchCount.add(1, { [AGENT_ID]: catId as string });
              }
            }

            worklist.push(nextCat);
            worklistEntry.a2aCount++;
            pendingTail.push(nextCat); // Keep dedup view in sync
            worklistEntry.a2aFrom.set(nextCat, catId);
            // F121: response-text path — set trigger message for auto-replyTo
            if (storedMsgId) worklistEntry.a2aTriggerMessageId.set(nextCat, storedMsgId);
            // F153: record mention parent span for dispatched target
            if (dispatchSpan) mentionParentSpan.set(worklist.length - 1, dispatchSpan);
          }
          // F153: end or defer dispatch span based on child execution
          if (dispatchSpan) {
            let maxChildIdx = -1;
            for (const [idx, s] of mentionParentSpan) {
              if (s === dispatchSpan && idx > maxChildIdx) maxChildIdx = idx;
            }
            if (maxChildIdx > index) {
              pendingDispatchSpans.push({ span: dispatchSpan, lastChildIndex: maxChildIdx });
            } else {
              dispatchSpan.end();
            }
          }
        } else if (a2aMentions.length > 0 && queuedMessagesPending && deferA2AEnqueue && !catSignal?.aborted) {
          // F185 Phase B: deferred enqueue — preserve A2A handoff behind non-agent entries
          const pendingTailDeferred = worklist.slice(index + 1);
          // F153 Phase I: lazy mention_dispatch span for deferred path (mirrors inline path at :1661-1675).
          // End span immediately because the child route runs through QueueProcessor in a separate
          // loop; the captured trace context is propagated via entry.callerTraceContext so the
          // dispatched route still parents itself under this dispatch span.
          let deferredDispatchCtx: CallerTraceContext | undefined;
          for (const nextCat of a2aMentions) {
            if (worklistEntry.a2aCount >= maxDepth) break;
            if (pendingTailDeferred.includes(nextCat)) continue;
            if (hasQueuedOrActiveAgentForCat && hasQueuedOrActiveAgentForCat(threadId, nextCat)) {
              log.info(
                { threadId, catId: nextCat, fromCat: catId },
                'A2A text-scan dedup (deferred): cat actively processing, skipping',
              );
              continue;
            }
            // F167 L1 + F185-B AC-B3a: ping-pong streak check for deferred path
            const hadSubstantiveToolCallDeferred = collectedToolNames.some((n) => isSubstantiveTool(n));
            const streakDeferred = updateStreakOnPush(worklistEntry, catId, nextCat, {
              hadSubstantiveToolCall: hadSubstantiveToolCallDeferred,
              outputLength: storedContent.length,
            });
            if (streakDeferred.blockPingPong) {
              log.info(
                { threadId, catId: nextCat, fromCat: catId, count: streakDeferred.count },
                'F167 L1: A2A ping-pong terminated in deferred path (streak >= 4)',
              );
              yield {
                type: 'system_info' as AgentMessageType,
                catId,
                content: JSON.stringify({
                  type: 'a2a_pingpong_terminated',
                  fromCatId: catId,
                  targetCatId: nextCat,
                  pairCount: streakDeferred.count,
                }),
                timestamp: Date.now(),
              } as AgentMessage;
              continue;
            }
            // F153 Phase I: create dispatch span on first real enqueue and capture its trace
            // context for cross-route causality.
            if (!deferredDispatchCtx) {
              const mentionerSpan = catInvocationSpans.get(index);
              if (mentionerSpan) {
                const parentCtx = trace.setSpan(context.active(), mentionerSpan);
                const dSpan = routeSerialTracer.startSpan(
                  'cat_cafe.mention_dispatch',
                  {
                    attributes: {
                      [AGENT_ID]: catId as string,
                      'dispatch.target_count': a2aMentions.length,
                      'dispatch.source': 'text-scan-deferred',
                    },
                  },
                  parentCtx,
                );
                a2aDispatchCount.add(1, { [AGENT_ID]: catId as string });
                const sc = dSpan.spanContext();
                dSpan.end();
                deferredDispatchCtx = {
                  traceId: sc.traceId,
                  spanId: sc.spanId,
                  traceFlags: sc.traceFlags,
                };
              }
            }
            deferA2AEnqueue({
              threadId,
              userId,
              content: storedContent,
              source: 'agent',
              sourceCategory: 'a2a',
              targetCats: [nextCat],
              callerCatId: catId,
              messageId: storedMsgId,
              a2aTriggerMessageId: storedMsgId,
              autoExecute: true,
              priority: 'normal',
              intent: 'execute',
              ...(deferredDispatchCtx ? { callerTraceContext: deferredDispatchCtx } : {}),
            });
            worklistEntry.a2aCount++;
            log.info(
              { threadId, catId: nextCat, fromCat: catId },
              'A2A text-scan deferred: enqueued behind non-agent entries (F185-B)',
            );
          }
        }

        // F27: Emit a2a_handoff for ALL new A2A targets (both response-text and callback-pushed).
        // We track which targets have already been announced to avoid duplicate handoff events.
        for (let wi = handoffEmitted; wi < worklist.length; wi++) {
          const pendingCat = worklist[wi]!;
          if (wi < targetCats.length) continue; // Skip original targets — not A2A

          // === A2A_HANDOFF 审计 (fire-and-forget, 缅因猫 review P2-3) ===
          const auditLog = getEventAuditLog();
          auditLog
            .append({
              type: AuditEventTypes.A2A_HANDOFF,
              threadId,
              data: {
                fromCat: catId,
                toCat: pendingCat,
                userId,
                a2aDepth: worklistEntry.a2aCount,
                maxDepth,
              },
            })
            .catch((err) => {
              log.warn({ threadId, fromCat: catId, toCat: pendingCat, err }, 'A2A_HANDOFF audit write failed');
            });

          const nextConfig: CatConfig | undefined = catRegistry.tryGet(pendingCat as string)?.config;
          if (options.invocationController && options.trackA2ASlot && !activeTrackedA2ASlots.has(pendingCat)) {
            options.trackA2ASlot(threadId, pendingCat, userId, options.invocationController);
            activeTrackedA2ASlots.add(pendingCat);
          }
          yield {
            type: 'a2a_handoff' as AgentMessageType,
            catId,
            content: formatA2AHandoffContent(catId, pendingCat, catConfig, nextConfig),
            invocationId: ownInvocationId,
            targetCatId: pendingCat,
            timestamp: Date.now(),
          } as AgentMessage;
        }
        handoffEmitted = worklist.length;
      } else if (!hadError) {
        // No text content and no error.
        // Persist only when we have non-text payload (tool/thinking/rich).
        // Purely empty turns should not create blank chat bubbles.
        const noTextBlocks = [...bufferedBlocks, ...streamRichBlocks];
        const hasRichBlocks = noTextBlocks.length > 0;
        const shouldPersistNoTextMessage =
          hasRichBlocks ||
          collectedToolEvents.length > 0 ||
          Boolean(renderThinkingChunks(thinkingChunks).trim().length > 0);
        const shouldEmitSilentCompletion = collectedToolEvents.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo;

        log.debug(
          {
            catId: catId as string,
            threadId,
            hasRichBlocks,
            sawUserFacingSystemInfo,
            toolCount: collectedToolEvents.length,
            shouldPersist: shouldPersistNoTextMessage,
            thinkingLen: renderThinkingChunks(thinkingChunks).length,
          },
          'Cat produced no text — evaluating silent_completion',
        );
        // Diagnostic: if cat ran tools but produced no text, emit a system_info so the
        // user sees *something* instead of a silent vanish (bugfix: silent-exit P1).
        if (shouldEmitSilentCompletion) {
          yield {
            type: 'system_info' as AgentMessageType,
            catId,
            content: JSON.stringify({
              type: 'silent_completion',
              detail: `${catConfig?.displayName ?? (catId as string)} completed with tool calls but no text response.`,
              toolCount: collectedToolEvents.length,
            }),
            timestamp: Date.now(),
          } as AgentMessage;
        }
        if (shouldPersistNoTextMessage || sawUserFacingSystemInfo || shouldEmitSilentCompletion) {
          catProducedOutput = true;
        }

        if (shouldPersistNoTextMessage) {
          try {
            await deps.messageStore.append({
              userId,
              catId,
              content: '',
              mentions: [],
              origin: 'stream',
              timestamp: invocationStartedAt,
              threadId,
              ...(streamReplyTo ? { replyTo: streamReplyTo } : {}),
              ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}),
              ...(firstMetadata ? { metadata: firstMetadata } : {}),
              ...(collectedToolEvents.length > 0 ? { toolEvents: collectedToolEvents } : {}),
              extra: {
                ...(noTextBlocks.length > 0 ? { rich: { v: 1 as const, blocks: noTextBlocks } } : {}),
                // F194 Phase Z9 AC-Z25 (KD-28): always stamp turnInvocationId
                // (= ownInvocationId, else parent fallback).
                ...((options.parentInvocationId ?? ownInvocationId)
                  ? {
                      stream: {
                        invocationId: (options.parentInvocationId ?? ownInvocationId) as string,
                        turnInvocationId: (ownInvocationId ?? options.parentInvocationId) as string,
                      },
                    }
                  : {}),
                ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}),
              },
            });
            // F088-P3: Stash rich blocks for outbound delivery (no-text branch)
            if (options.persistenceContext && noTextBlocks.length > 0) {
              options.persistenceContext.richBlocks = [
                ...(options.persistenceContext.richBlocks ?? []),
                ...noTextBlocks,
              ];
            }
            // #80: Clean up draft only after successful append
            if (deps.draftStore && ownInvocationId) {
              deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
            }
            // Cloud Codex R4 P1 fix: Update activity in isolated try/catch to not affect append status
            if (deps.invocationDeps.threadStore) {
              try {
                await deps.invocationDeps.threadStore.updateParticipantActivity(
                  threadId,
                  catId,
                  // #267: only errors before abort are provider failures
                  !hadProviderError,
                );
              } catch (activityErr) {
                log.warn({ catId: catId as string, err: activityErr }, 'updateParticipantActivity failed');
              }
            }
          } catch (err) {
            log.error({ catId: catId as string, err }, 'messageStore.append failed, degrading');
            if (options.persistenceContext) {
              options.persistenceContext.failed = true;
              options.persistenceContext.errors.push({
                catId: catId as string,
                error: err instanceof Error ? err.message : String(err),
              });
            }
          }
        } else if (!sawUserFacingSystemInfo) {
          yield {
            type: 'system_info' as AgentMessageType,
            catId,
            content: JSON.stringify({
              type: 'silent_completion',
              detail: `${catConfig?.displayName ?? (catId as string)} completed without textual output.`,
              toolCount: collectedToolEvents.length,
              provider: firstMetadata?.provider,
              model: firstMetadata?.model,
              invocationId: ownInvocationId,
            }),
            timestamp: Date.now(),
          } as AgentMessage;
          // No persisted message for fully silent turns.
          if (deps.draftStore && ownInvocationId) {
            deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
          }
        } else if (deps.draftStore && ownInvocationId) {
          deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
        }
      } else if (collectedToolEvents.length > 0) {
        // hadError && textContent === '' but toolEvents exist — persist tool record so
        // refreshing the page still shows what the cat attempted before the error.
        try {
          await deps.messageStore.append({
            userId,
            catId,
            content: '',
            mentions: [],
            origin: 'stream',
            timestamp: invocationStartedAt,
            threadId,
            ...(streamReplyTo ? { replyTo: streamReplyTo } : {}),
            ...(firstMetadata ? { metadata: firstMetadata } : {}),
            toolEvents: collectedToolEvents,
            ...((options.parentInvocationId ?? ownInvocationId) || doneMsg?.tracing
              ? {
                  extra: {
                    // F194 Phase Z9 AC-Z25 (KD-28): always stamp turnInvocationId
                    // for error+toolEvents records too.
                    ...((options.parentInvocationId ?? ownInvocationId)
                      ? {
                          stream: {
                            invocationId: (options.parentInvocationId ?? ownInvocationId) as string,
                            turnInvocationId: (ownInvocationId ?? options.parentInvocationId) as string,
                          },
                        }
                      : {}),
                    ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}),
                  },
                }
              : {}),
          });
          // #80: Clean up draft only after successful append
          if (deps.draftStore && ownInvocationId) {
            deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
          }
          // Cloud Codex R4 P1 fix: Update activity in isolated try/catch to not affect append status
          if (deps.invocationDeps.threadStore) {
            try {
              await deps.invocationDeps.threadStore.updateParticipantActivity(
                threadId,
                catId,
                // #267: only errors before abort are provider failures
                !hadProviderError,
              );
            } catch (activityErr) {
              log.warn({ catId: catId as string, err: activityErr }, 'updateParticipantActivity failed');
            }
          }
        } catch (err) {
          log.error({ catId: catId as string, err }, 'messageStore.append (error+tools) failed, degrading');
          if (options.persistenceContext) {
            options.persistenceContext.failed = true;
            options.persistenceContext.errors.push({
              catId: catId as string,
              error: err instanceof Error ? err.message : String(err),
            });
          }
        }
      } else {
        // hadError && textContent === '' && no toolEvents → clean up draft only
        if (deps.draftStore && ownInvocationId) {
          deps.draftStore.delete(userId, threadId, ownInvocationId)?.catch?.(noop);
        }
        // Update activity for error-only responses (no text/tools branch handles it)
        if (deps.invocationDeps.threadStore) {
          try {
            await deps.invocationDeps.threadStore.updateParticipantActivity(threadId, catId, !hadProviderError);
          } catch (activityErr) {
            log.warn({ catId: catId as string, err: activityErr }, 'updateParticipantActivity failed');
          }
        }
      }

      // F27: Emit a2a_handoff for ALL new A2A targets (both response-text and callback-pushed).
      // Keep this outside the text branch: callback/tool-only turns can push worklist entries
      // without producing text, but their child slots still must be tracked before parent done.
      // We track which targets have already been announced to avoid duplicate handoff events.
      for (let wi = handoffEmitted; wi < worklist.length; wi++) {
        const pendingCat = worklist[wi]!;
        if (wi < targetCats.length) continue; // Skip original targets — not A2A

        // === A2A_HANDOFF 审计 (fire-and-forget, 缅因猫 review P2-3) ===
        const auditLog = getEventAuditLog();
        auditLog
          .append({
            type: AuditEventTypes.A2A_HANDOFF,
            threadId,
            data: {
              fromCat: catId,
              toCat: pendingCat,
              userId,
              a2aDepth: worklistEntry.a2aCount,
              maxDepth,
            },
          })
          .catch((err) => {
            log.warn({ threadId, fromCat: catId, toCat: pendingCat, err }, 'A2A_HANDOFF audit write failed');
          });

        const nextConfig: CatConfig | undefined = catRegistry.tryGet(pendingCat as string)?.config;
        if (options.invocationController && options.trackA2ASlot && !activeTrackedA2ASlots.has(pendingCat)) {
          options.trackA2ASlot(threadId, pendingCat, userId, options.invocationController);
          activeTrackedA2ASlots.add(pendingCat);
        }
        yield {
          type: 'a2a_handoff' as AgentMessageType,
          catId,
          content: formatA2AHandoffContent(catId, pendingCat, catConfig, nextConfig),
          invocationId: ownInvocationId,
          targetCatId: pendingCat,
          timestamp: Date.now(),
        } as AgentMessage;
      }
      handoffEmitted = worklist.length;

      // Persist error as system message so it survives F5 reload.
      // During streaming, errors render as red badges via ephemeral frontend state.
      // Without persistence, they vanish on page refresh.
      if (collectedErrorText) {
        try {
          await deps.messageStore.append({
            userId: 'system',
            catId: null,
            content: `Error: ${collectedErrorText}`,
            mentions: [],
            origin: 'stream',
            timestamp: Date.now(),
            threadId,
            // F212 Phase B (云端 codex P2-8): carry cliDiagnostics through to persistence
            // so cold hydration / F5 reload can re-render the folded panel.
            ...(collectedCliDiagnostics
              ? { metadata: { provider: '', model: '', cliDiagnostics: collectedCliDiagnostics } }
              : {}),
          });
        } catch (err) {
          log.error({ catId: catId as string, err }, 'messageStore.append (error system msg) failed');
        }
      }

      // Ack cursor regardless of hadError: messages were assembled into the prompt
      // and delivered to the cat. Not acking causes infinite re-delivery on subsequent
      // rounds (bug: "砚砚每次都疯狂回之前的消息").
      if (incrementalMode && deliveryBoundaryId) {
        if (options.cursorBoundaries) {
          // ADR-008 S3: defer ack — caller acks after completion (or on abort/exception)
          upsertMaxBoundary(options.cursorBoundaries, catId, deliveryBoundaryId);
        } else if (deps.deliveryCursorStore) {
          // Legacy: ack immediately (deprecated route() path)
          try {
            await deps.deliveryCursorStore.ackCursor(userId, catId, threadId, deliveryBoundaryId);
          } catch (err) {
            log.error({ catId: catId as string, err }, 'ackCursor failed');
          }
        }
      }

      // F148 OQ-2: Log briefing→invocation link + context eval signals
      if (briefingMessageId && ownInvocationId) {
        const evalSignals = briefingCoverageMap
          ? extractContextEvalSignals({
              coverageMap: briefingCoverageMap,
              toolNames: collectedToolNames,
              responseTokenEstimate: estimateTokens(textContent),
            })
          : undefined;
        log.info({
          f148: 'briefing-invocation-link',
          briefingMessageId,
          invocationId: ownInvocationId,
          catId,
          threadId,
          hadError: hadProviderError,
          ...(evalSignals ? { eval: evalSignals } : {}),
        });
      }

      // F155: Ack guide completion only after cat produced visible output.
      if (deps.invocationDeps.threadStore) {
        const { createGuideStoreBridge } = await import('../../../../guides/GuideSessionRepository.js');
        const sessionStore = deps.invocationDeps.guideSessionStore!;
        await ackGuideCompletion({
          ctx: guideCtx,
          catId,
          catProducedOutput,
          targetCatIds,
          threadId,
          userId,
          guideStore: createGuideStoreBridge(sessionStore),
          threadStore: deps.invocationDeps.threadStore!,
        });
      }

      // Yield buffered done with correct isFinal (evaluated AFTER worklist may have grown)
      // MUST always reach here regardless of append success (缅因猫 review P1-2)
      // F194 Phase Z9 砚砚 R1 P1-1: stamp ownInvocationId on done if not already set.
      if (doneMsg) {
        const isFinal = index === worklist.length - 1;
        const ownStampedDone =
          ownInvocationId && !doneMsg.invocationId ? { ...doneMsg, invocationId: ownInvocationId } : doneMsg;
        yield { ...ownStampedDone, ...(mentionsUser ? { mentionsUser } : {}), isFinal };
        activeTrackedA2ASlots.delete(catId);
        if (isFinal) yieldedFinalDone = true;
        if (ownInvocationId) completedCatInvocationIds.push([catId, ownInvocationId]);
      }

      // F27: Advance executedIndex so pushToWorklist knows which cats are done
      worklistEntry.executedIndex = index + 1;
      index++;
    }
  } finally {
    // F153: Set route aggregate attributes on the parent route span
    if (options.routeSpan) {
      options.routeSpan.setAttribute(ROUTE_TOTAL_CATS_INVOKED, index);
      options.routeSpan.setAttribute(ROUTE_TOTAL_TOKENS, routeTotalTokens);
      options.routeSpan.setAttribute(ROUTE_HAS_A2A_HANDOFF, worklist.length > targetCats.length);
    }
    // F153: End all pending dispatch spans (unconditional — covers abort/throw)
    for (const entry of pendingDispatchSpans) {
      entry.span.end();
    }

    if (options.invocationController && options.completeA2ASlots && activeTrackedA2ASlots.size > 0) {
      options.completeA2ASlots(threadId, [...activeTrackedA2ASlots], options.invocationController);
    }

    // F200 AC-A1: fire-and-forget recall correlation after all cats complete
    if (deps.toolEventLog && deps.evidenceStore && completedCatInvocationIds.length > 0) {
      const evidenceDb = (deps.evidenceStore as { getDb?: () => import('better-sqlite3').Database }).getDb?.();
      if (evidenceDb) {
        deps.toolEventLog
          .readByThread(threadId)
          .then((events) => {
            const raw = events as unknown as Parameters<typeof triggerRecallCorrelation>[1];
            for (const [catId, invId] of completedCatInvocationIds) {
              triggerRecallCorrelation(evidenceDb, raw, invId, catId).catch(() => {});
            }
          })
          .catch(() => {});
      }
    }

    // F27: Always unregister worklist, even on error/abort.
    // Pass owner ref so preempting new invocation's worklist is not deleted (缅因猫 R1 P1-1)
    unregisterWorklist(threadId, worklistEntry, options.parentInvocationId);

    // done-guarantee safety net: If loop exited without yielding a final done
    // (e.g. signal.aborted break at top of while, or provider threw before done),
    // synthesize one so the frontend always receives isFinal=true and clears its timer.
    if (!yieldedFinalDone && worklist.length > 0) {
      const lastCatId = worklist[Math.min(index, worklist.length - 1)]!;
      yield {
        type: 'done' as AgentMessageType,
        catId: lastCatId,
        isFinal: true,
        timestamp: Date.now(),
      } as AgentMessage;
    }
  }
}
