import type { AssignmentProposal, SheetEditOp } from '@pac/types';

/**
 * chat-blocks —— 助手消息流的**数据模型 + 纯函数操作**。
 *
 * 从 `use-assistant-chat` 抽出来的原因：草稿状态机（active / superseded / …）
 * 是这条链路上最容易出错、也最值得回归的一段逻辑，而它原来内联在 React hook 里，
 * 只能靠端到端点一遍来验。抽成纯函数之后可以直接测。
 *
 * ⛔ 这里只放**不碰 React、不碰网络**的东西。带副作用的仍留在 hook 里。
 */

/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
  id: string;
  /** 后端 toolCallId,用于把 tool_result/tool_error 精确匹配回对应步骤(支持并行/多调用)。 */
  callId?: string;
  tool: string;
  args: unknown;
  result?: unknown;
  /**
   * ⚠️ `aborted` 是**第四态**，⛔ 别并进 `error`（2026-08-15 对抗审查抓到）。
   *
   * 点「停止」或连接断掉时，服务端那次调用**可能已经跑完了** —— 我们只是不再听。
   *   写成 `error` 就是替它宣布失败，而 `propose_assignment` 这类真写库的工具，
   *   一句「失败」会让主管以为那批没出，转身又发一遍。
   * ⚠️ 也⛔ 不能留在 `running`：那一行会永远写着「进行中…」，
   *   而这一轮早就结束了（GeneratingHint 跟着 streaming 一起消失）。
   */
  status: 'running' | 'done' | 'error' | 'aborted';
  error?: string;
}

/** 模型产出的 HTML 卡片(在输出区沙箱 iframe 渲染)。 */
export interface Artifact {
  id: string;
  title?: string;
  html: string;
  /** true = 还在流式生成中(只注入内容,图表脚本不执行);false/缺省 = 最终(执行图表脚本)。 */
  streaming?: boolean;
}

/**
 * 草稿状态机 —— **任一时刻最多只有一张 `active`**。
 *
 * ⭐ `superseded` = 主管让助手重出了一版，这张当场作废。
 *   🔴 作废发生在**新版生成时**，⛔ 不是在确认时（产品定，2026-08-12）：
 *   否则主管面前会同时摆着两张都能点确认的卡，点错就是分错一批人。
 * ⚠️ 2026-08-13 改判（产品定，推翻此前"作废的留在对话里并显式标注"）：
 *   **作废那一版整条不再显示**（见 isDiscardedDraft）。它一个字都没落库，
 *   人数、分给谁全是废数，却在对话里占着大半屏 —— 往回翻读到的是一份不存在的方案。
 *   「他要能看见自己出过几版」由**主管自己那两句话**保住：
 *   「出一份分配方案」→「每人每天按 20 通算，重新排一版」，一眼看得出重排过。
 * ⚠️ `confirmed` / `cancelled` 是**终态**，⛔ 不许被后续的作废扫描回退。
 */
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';

export type Block =
  /**
   * 文本块。`modelText` = **只给模型看**的版本(缺省时模型看 `text`)。
   *
   * 🔴 为什么需要两份:确认后注入的那句话里要带**完整批次 uuid**,否则模型撤销时
   *   只能拿界面上的 8 位短号去调 `revoke_assignment` —— 实测必然报「批次不存在」。
   *   但 uuid 对主管毫无意义,摆在对话里纯属噪音。⇒ 显示一份、喂模型一份。
   * ⚠️ 用它承载的必须是**同一件事的两种措辞**,⛔ 不许塞模型专属的隐藏指令:
   *   主管看不见的话就无法纠正,那是"界面替主管说话"。
   */
  | { kind: 'text'; text: string; modelText?: string }
  /**
   * 模型的**思考**（reasoning）—— 跟正文走同一条流，按到达顺序穿插。
   *
   * ⭐ 它回答的是「为什么这么调工具」。2026-08-14 实测：模型在思考里写下
   *   「注意：show_guidance 一次只开放一件」，那是**读了我们工具描述之后**的结论 ——
   *   没有这块，归因只能靠猜，而猜的方向大概率是"模型不行"。
   * ⚠️ 渲染上默认收起（几百上千字），⛔ 但位置不许挪：它落在哪一步之前，
   *   本身就是信息（写正文前想的？调工具前想的？拿到结果之后想的？）。
   * ⚠️ ⛔ 不进历史回传的正文 —— 它由 `messages` 那条结构化通道原样带回（见 toApiMessages）。
   */
  | { kind: 'reasoning'; text: string }
  | { kind: 'tool'; step: ToolStep }
  | { kind: 'artifact'; artifact: Artifact }
  /**
   * 引导节点的**选项按钮**，位置由模型决定（`show_guidance` 工具）。
   *
   * ⭐ 模型写完「389 人卡在待分配…」那一条，紧接着调一次工具 → 这个块就插在
   *   消息流的**此刻这个位置**，按钮正好落在它的解释下面。
   *   ⚠️ 和 artifact 同一个模式：工具调用即"在流里插一个界面块"。
   * ⚠️ 只存 `signalKey`，⛔ 不存整个 signal —— 确认单可能被重排替换（refill），
   *   届时按 key 从**当前**那张单上取，取不到就不渲染（那条已经不存在了）。
   * 🔴 漏调不丢东西：没被插进正文的 action 节点仍旧显示在确认单卡片上（卡片按
   *   已插入的 key 去重）。⇒ 这个块优化的是**位置**，⛔ 不是可见性。
   */
  | { kind: 'guidance'; signalKey: string }
  /**
   * 确认单卡片**摆在这** —— 位置由模型决定（`show_sheet` 工具）。
   *
   * ⚠️ 它只是个**位置标记**，自己不渲染任何东西：真正的卡片是同一条消息里那个
   *   `assignment_sheet` 块，渲染时被搬到这个标记的位置。
   * 🔴 没有这个标记时卡片仍旧钉在消息末尾（今天的行为）—— ⛔ 漏调不会让卡片消失。
   *   它是整条链上唯一会写库的东西，位置可以让模型定，**存在与否不行**。
   */
  | { kind: 'sheet_anchor' }
  /**
   * 全景确认单 —— **原生 React 组件**,不是 artifact。
   *
   * ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
   * CSP 是 `connect-src 'none'` —— **卡片内不可能发出写请求**。确认单要能点确认落库,
   * 只能是原生组件。
   * 分岔记牢:**只读展示用 artifact,可交互用原生组件。**
   *
   * ⚠️ sheet 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId,
   * 让模型吐等于让它编。
   */
  | {
      kind: 'assignment_sheet';
      requestId: string;
      sheet: AssignmentProposal;
      state: DraftState;
      /** 确认后的批次 id(终态展示用) */
      assignmentId?: string;
      /**
       * ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
       * 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
       * ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
       */
      edits?: { seq: number; ops: SheetEditOp[] }[];
    };

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  blocks: Block[];
}

export type ChatStatus = 'idle' | 'streaming' | 'error';

// ─────────────────────────────────────────────────────────
// 纯函数操作
// ─────────────────────────────────────────────────────────

/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
export function findToolIdx(blocks: Block[], callId: string | undefined): number {
  if (callId) {
    const i = blocks.findIndex((b) => b.kind === 'tool' && b.step.callId === callId);
    if (i !== -1) return i;
  }
  for (let i = blocks.length - 1; i >= 0; i--) {
    const b = blocks[i];
    if (b && b.kind === 'tool' && b.step.status === 'running') return i;
  }
  return -1;
}

/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
export function upsertArtifact(
  blocks: Block[],
  id: string,
  patch: { title?: string; html?: string; streaming?: boolean },
): Block[] {
  const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
  if (i === -1) {
    return [
      ...blocks,
      {
        kind: 'artifact',
        artifact: { id, title: patch.title, html: patch.html ?? '', streaming: patch.streaming },
      },
    ];
  }
  return blocks.map((b, j) => {
    if (j !== i || b.kind !== 'artifact') return b;
    return {
      kind: 'artifact',
      artifact: {
        id,
        title: patch.title ?? b.artifact.title,
        html: patch.html ?? b.artifact.html,
        streaming: patch.streaming ?? b.artifact.streaming,
      },
    };
  });
}

/**
 * 新草稿到达 —— upsert 它，并把**此前所有 `active` 的当场作废**。
 *
 * 🔴 必须**跨消息**扫：上一版几乎总在**上一轮**的消息里
 *   （主管看着它才说「重排一版」的）。只扫当前消息永远扫不到，而且不会报错。
 * ⚠️ 只作废 `active`：`confirmed` / `cancelled` 是终态，⛔ 不许回退。
 * ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。
 *
 * @param hostMessageId 新卡挂到哪条消息下（通常是当前这条助手消息）
 */
export function applyIncomingSheet(
  messages: ChatMessage[],
  input: { requestId: string; sheet: AssignmentProposal; hostMessageId: string },
): ChatMessage[] {
  const { requestId, sheet, hostMessageId } = input;
  let placed = false;
  const swept = messages.map((m) => ({
    ...m,
    blocks: m.blocks.map((b): Block => {
      if (b.kind !== 'assignment_sheet') return b;
      if (b.requestId === requestId) {
        placed = true;
        return { ...b, sheet, state: 'active', edits: [] };
      }
      return b.state === 'active' ? { ...b, state: 'superseded' } : b;
    }),
  }));
  if (placed) return swept;
  return swept.map((m) =>
    m.id === hostMessageId
      ? { ...m, blocks: [...m.blocks, { kind: 'assignment_sheet', requestId, sheet, state: 'active' }] }
      : m,
  );
}

/**
 * 找**他现在说的那张**确认单 —— 助手的修改指令打到这张上。
 *
 * ⭐ 2026-08-12 由「最后一张」改成「唯一 active」：重出一版之后，最后一张可能
 *   正是刚作废的那张 —— 助手的修改指令打上去，主管一点反应都看不到。
 * ⚠️ 模型手里没有 requestId（服务端铸的，没进它上下文），它说「这批」指的就是眼前那张。
 *
 * 🔴🔴 **2026-08-14 补 `confirmed`：只认 `active` 时，确认之后的补挂福利整条路是死的。**
 *   实测：确认完说「这批带上『老客户复查免挂号费』」——
 *   助手调了 `edit_assignment_sheet`，然后回「福利已挂上，会进这批人此后生成的话术里」，
 *   而 `plan_assignments.attributes` **是空的**，接口一次都没被调过。
 *   原因就在这里：确认后那张单的 state 是 `confirmed`，`findActiveSheet` 返回 null，
 *   指令被**静默丢弃** —— 连"没改成"这句都没有，于是模型只能按"我发出去了"往下说。
 *   ⚠️ 这条路是产品明确承诺的（提示词：「确认之后福利仍然可以补挂」），卡片里也早就写了
 *   `if (assignmentId)` 那段专门处理它 —— 缺的只是**没人把指令送到**。
 *
 * ⚠️ 优先级不能反：`active`（还能改的那张）永远压过 `confirmed`。
 *   ⛔ 也永远不要选 `superseded` / `cancelled`：那两张改了什么都不会发生。
 */
export function findActiveSheet(
  messages: ChatMessage[],
): { messageIndex: number; blockIndex: number } | null {
  const pick = (want: 'active' | 'confirmed') => {
    for (let mi = messages.length - 1; mi >= 0; mi--) {
      const blocks = messages[mi]?.blocks ?? [];
      const bi = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.state === want);
      if (bi >= 0) return { messageIndex: mi, blockIndex: bi };
    }
    return null;
  };
  return pick('active') ?? pick('confirmed');
}

// ─────────────────────────────────────────────────────────
// 引导节点的 intent 路由
// ─────────────────────────────────────────────────────────

/**
 * intent → 卡片上的语义编辑指令。
 *
 * ⭐ **按钮和助手走的是同一条队列**：助手的 `edit_assignment_sheet` 推的是
 *   `SheetEditOp[]`，这里把按钮点击也翻成同一种 op 压进去。
 *   ⛔ 别为按钮另写一套执行路径 —— 两条路各做各的，行为必然漂，而漂了不报错。
 *
 * 返回 `null` = 这个 intent 不是局部改单能完成的（要重跑算法），交给 `intentToPrompt`。
 */
export function intentToSheetOps(
  intent: string,
  args?: Record<string, unknown>,
): SheetEditOp[] | null {
  switch (intent) {
    case 'pending.to_owner':
      // ⚠️ owner 与 balance **结果正好相反**，选错主管一眼看得出来（实测栽过：
      //   18 个人被散给了 17 位别人）。按钮这条路没有理解环节，所以不会选错。
      return [{ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } }];
    case 'pending.spread':
      return [{ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } }];
    case 'pending.remove':
      return [{ select: { group: 'pending' }, action: 'remove' }];
    case 'expiry.set':
      return typeof args?.days === 'number'
        ? [{ select: { group: 'batch' }, action: 'set_expiry', days: args.days }]
        : null;
    default:
      return null;
  }
}

/**
 * intent → 替主管说的那句话（交给助手重跑算法）。
 *
 * ⚠️ 这几个都要**换人群或换基数**，必须重出一版，⛔ 不能用局部改单去凑
 *   （凑出来的单子人群没变，主管以为条件生效了、其实没有 —— 静默错）。
 * ⚠️ 措辞用**主管会说的话**，⛔ 不带 intent id、⛔ 不带字段名 ——
 *   这句会原样出现在对话里，他要能认出这是自己刚点的那一下。
 */
export function intentToPrompt(
  intent: string,
  args?: Record<string, unknown>,
): string | null {
  switch (intent) {
    case 'cohort.narrow': {
      // 🔴 用**中文名 + 人数**，⛔ 不用内部筛选串：这句会原样出现在对话框里，
      //   主管看到 `referral_champion:yes` 的第一反应是系统坏了。
      //   人数带上，是因为「选完还剩多少」正是他点这一下想知道的事。
      const zh = typeof args?.labelZh === 'string' ? args.labelZh : null;
      if (!zh) return null;
      const n = typeof args?.count === 'number' ? `那 ${args.count} 人` : '这些人';
      /**
       * 消费那一刀要**把门槛的数字说出来**（`propose_assignment.minSpendYuan` 收的就是元）。
       *
       * 🔴 只说「只选消费高的那 312 人」不行：模型没有那个数就只能自己拍一个，
       *   拍出来的批次和按钮上写的 312 人对不上，而且不报错。⇒ 数字进话里。
       * ⚠️ 单位是**元**，与工具参数逐字一致；⛔ 别在这里换成「万」或加千分位符号 ——
       *   模型要把它读回一个数，`8,200` 比 `8200` 更容易读成 8。
       */
      const yuan = typeof args?.minSpendYuan === 'number' ? args.minSpendYuan : null;
      if (yuan != null) return `这批只选累计消费高于 ${yuan} 元的${n}，重新排一版`;
      return `这批只选「${zh}」${n}，重新排一版`;
    }
    /**
     * 「按别的条件选」—— ⛔ 不改任何东西，只是把「先看看还有哪些口子」做成一个按钮。
     * ⚠️ 措辞里要写明**别急着重排**：不然模型会顺手出一版新确认单，
     *   而主管这一下问的是"有哪些选择"，不是"照某个条件重来"。
     */
    case 'cohort.attrs_ask':
      return '这批人还能按哪些条件筛，各有多少人？先说说有哪些口子，先别重新排';
    // 🔴 `cohort.widen`（「往后放一档 / 换个治疗项」）连同它的节点 `short_supply`
    //   删于 2026-08-14：换格子是主管在矩阵上一点的事，而矩阵每格都写着人数，
    //   卡片上的按钮反而不带数 ⇒ 严格弱于矩阵。⛔ 别加回来。
    /**
     * 「本批多大」那个式子改了一项 → **重跑算法**（N = 在岗 × 每天几通 × 时限）。
     * ⭐ 带上主管填的那个数：propose_assignment 有 `expiresInDays` / `dailyCalls` 两个参数,
     *   **服务端**据此重估 N。⛔ 别让模型自己去乘 —— 报出去的数必须是工具算的。
     * ⚠️ ⛔ 别退化成 `expiry.set`：那个只改到期日，人数一个不变，而节点原话说会变。
     */
    case 'basis.set': {
      if (typeof args?.days === 'number') return `时限改成 ${args.days} 天，重新排一版`;
      if (typeof args?.rate === 'number') return `每人每天按 ${args.rate} 通算，重新排一版`;
      return null;
    }
    /**
     * 🔴 `batch_size.set` / `daily_rate.set` **删于 2026-08-15**（连同发出它们的两颗按钮）。
     *   它们替主管说的是「这批人数**减少一些**」「按**更少的**通数算」—— 一个数都没有，
     *   模型只能自己拍。带数的那条走 `basis.set`（输入框预填当前值）。
     *   ⛔ 别加回来：判据与上面 `cohort.widen` 那条相同。
     */
    default:
      return null;
  }
}

// ─────────────────────────────────────────────────────────
// 历史回传（分层）
// ─────────────────────────────────────────────────────────

/**
 * 剥掉模型**照抄出来的工具记录**。
 *
 * 🔴 这些标记（`<tool …>` / `<sheet …/>` / `<artifact …/>`）是**旧的拍平版历史**造给
 *   模型自己看的结构，⛔ 从来不是输出格式。可模型一再把它们当格式抄进正文：
 *     · 老格式 `〔调用 propose_assignment …〕` —— 中文书名号，混在中文里毫无违和感;
 *     · 换成 XML 之后 **qwen3.8-max 照抄不误**，连工具返回里的
 *       「⛔ 不要自己宣布已完成」这句内部叮嘱都一起贴给了主管。
 * ⇒ 换形状堵不住。2026-08-14 历史改成结构化之后**源头没了**（模型再也见不到这种文本），
 *   这里作为**显示层保险**保留：万一它凭训练记忆写出来，主管也不该看到。
 *   ⚠️ 回传那一侧不用剥了 —— 结构化历史里正文就是正文。
 *
 * ⚠️ 最后一条正则管**流式到一半**的半截标签（`<tool name="propo`）——
 *   不剥的话主管会看着一串尖括号一个字一个字长出来。
 * ⚠️ 这几个标签名是我们自己造的，模型写出来**一定**是照抄，⛔ 不存在误伤正文的情况。
 */
export function stripToolEcho(text: string): string {
  return text
    .replace(/<tool\b[^>]*>[\s\S]*?<\/tool>/g, '')
    .replace(/<(?:tool|sheet|artifact)\b[^>]*\/?>/g, '')
    .replace(/<(?:tool|sheet|artifact)\b[^>]*$/, '')
    .replace(/[ \t]{2,}/g, ' ')
    .trim();
}

/**
 * 这条助手消息是不是「**只剩一版已作废的确认单**」—— 即整条已经是废纸。
 *
 * 🔴 **界面和上下文共用这一个判据**（2026-08-13）。两边各写一份必然漂：
 *   界面上藏了、上下文里还留着，等于主管看不见但模型还在拿它当依据 —— 最难查的那种。
 * ⚠️ 判据共用，**处置略有不同**：界面整条不渲染；上下文留一行「已作废」说明
 *   （见 toApiMessage）—— 那一行主管看不见，作用是别让「重新排一版」在模型眼里没了指代。
 *
 * ⚠️ 判据是「**只剩**作废的」，⛔ 不是「有作废的」：同一轮里模型可能出过两版
 *   （`applyIncomingSheet` 把旧的**就地**扫成作废、新的追加到同一条消息）。
 *   那时这条消息里还有活着的那版，整条藏掉会把有效的一起抹了。
 * ⚠️ 只认 `superseded`（重排顶掉的、一条都没落库）。
 *   ⛔ `cancelled`（已确认后又撤销）与 `confirmed` 都是真发生过的事，不能藏也不能清。
 */
export function isDiscardedDraft(m: ChatMessage): boolean {
  const sheets = m.blocks.filter((b) => b.kind === 'assignment_sheet');
  return sheets.length > 0 && sheets.every((b) => b.state === 'superseded');
}


/** 裸模式回传给后端的消息形状 —— 对齐 AI SDK 的 `ModelMessage`（后端原样转给 provider）。 */
export type ApiMessage =
  | { role: 'user'; content: string }
  | {
      role: 'assistant';
      content:
        | string
        | Array<
            | { type: 'text'; text: string }
            | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
          >;
    }
  | {
      role: 'tool';
      content: Array<{
        type: 'tool-result';
        toolCallId: string;
        toolName: string;
        output: { type: 'text'; value: string };
      }>;
    };

/**
 * 历史构造 —— 一条界面消息 → 0~2 条 API 消息（assistant + 它的 tool 结果）。
 *
 * ═══ 🔴 ⛔ 别改回"把历史拍平成文本" ═══════════════════════════════════
 * 2026-08-14 之前这里是把整条消息拼成一个字符串（`<tool name="…">{…}</tool>` 混在正文里）。
 * 那条路上踩过的三个坑，**换个形状都堵不住**，因为病根是"历史里有可模仿的文本"：
 *
 *   ① 模型把工具记录**当成输出格式照抄给主管**。先是中文书名号
 *      （`〔调用 propose_assignment …〕`，混在中文里毫无违和感），换成 XML 之后
 *      qwen3.8-max 照抄不误，连工具返回里的内部叮嘱都一起贴进了对话框。
 *      ⇒ 只能在渲染前后各剥一次（`stripToolEcho` 至今保留，作显示层保险）。
 *   ② 为了躲 ① 而**抹掉** `show_sheet` / `show_guidance` 的记录，副作用是模型
 *      不知道自己上一轮摆过位置 —— 实测它对主管说「我只调了 2 个工具」，
 *      而那一轮执行过程是 6 步。
 *   ③ 为了补 ② 而留一个**无参数无返回值的位置空壳**（`<按钮位置 />`），当场翻车：
 *      模型把标记当成输出格式直接写给主管看，而**一个工具都没调**。
 *
 * ⇒ 结构化之后这三个坑一起消失：工具调用是模型真正认得的 `tool-call` / `tool-result`，
 *   历史里**没有任何文本可供模仿**，也不需要抹掉任何东西。
 * ⚠️ 代价是**完整回传**：作废那一版的正文和它的工具返回都还在上下文里
 *   （拍平版当年会把整条换成一行「已作废」）。裸台第二轮实测没有引用旧数 ——
 *   结构化的归属关系比一段文字清楚。⛔ 但这是要盯的一条：真出现引用旧数，
 *   正解是**丢掉整条**（assistant + tool 一起丢，⛔ 别只丢一半，那会破坏配对）。
 *
 * ⚠️ 只回传**配得上对的**调用：`callId` 缺失或这一步还在 running（本轮被打断）时整对丢掉 ——
 *   tool-call 没有对应的 tool-result 会让 provider 直接 400（DeepSeek 有 issue 记着）。
 * ⚠️ `assignment_sheet` / `artifact` 块**不回传**：它们是侧信道推给界面的渲染产物，
 *   模型当初就没从 API 收到过它们 —— "模型看到什么，就还它什么"。
 */
export function toApiMessages(m: ChatMessage): ApiMessage[] {
  if (m.role === 'user') {
    const text = m.blocks
      .map((b) => (b.kind === 'text' ? b.text : ''))
      .join('')
      .trim();
    return text ? [{ role: 'user', content: text }] : [];
  }

  const parts: Array<
    | { type: 'text'; text: string }
    | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
  > = [];
  const results: Array<{
    type: 'tool-result';
    toolCallId: string;
    toolName: string;
    output: { type: 'text'; value: string };
  }> = [];

  for (const b of m.blocks) {
    if (b.kind === 'text') {
      // ⚠️ 裸模式**不剥**工具回声：模型抄了什么就还它什么,那本身是要观察的现象之一
      const t = (b.modelText ?? b.text).trim();
      if (t) parts.push({ type: 'text', text: t });
      continue;
    }
    if (b.kind !== 'tool') continue;
    const { callId, tool, args, result, status, error } = b.step;
    if (!callId || status === 'running') continue;
    parts.push({ type: 'tool-call', toolCallId: callId, toolName: tool, input: args ?? {} });
    results.push({
      type: 'tool-result',
      toolCallId: callId,
      toolName: tool,
      output: {
        type: 'text',
        value:
          status === 'error'
            ? `工具失败：${error ?? '未知错误'}`
            : typeof result === 'string'
              ? result
              : JSON.stringify(result ?? ''),
      },
    });
  }

  if (parts.length === 0) return [];
  const out: ApiMessage[] = [{ role: 'assistant', content: parts }];
  if (results.length > 0) out.push({ role: 'tool', content: results });
  return out;
}

