Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

utils/messages.ts(约 5500 行)是 Claude Code 对话数据的「语法层」:把 API/SDK 的 ContentBlock、工具结果、系统事件统一封装为应用内的 Message 联合类型,并提供中断文案、权限拒绝模板、normalizeMessages 拆分多 block、ensureToolResultPairing 修复 tool_use/tool_result 配对等关键能力。本章要求你能从 REPL 里看到的一条用户消息,反查到是哪一个 createUserMessage 分支造出来的。

学完本章你应该能

  • 列举 INTERRUPT / REJECT / SYNTHETIC 等常量及其模型侧语义
  • 解释 normalizeMessages 为何在多块内容时重算 UUID 链
  • 说明 isSyntheticMessage 与训练数据防护的关系
  • 能在权限拒绝场景下选对 AUTO_REJECT 与 buildYoloRejectionMessage
  • 理解 ensureToolResultPairing 在 resume 与 strict 模式下的行为差异

核心概念(先读懂这些)

Message 不是 API Message 的简单别名

应用层 Message 在 types/message.ts 定义,包含 progress、attachment、system 等 API 不会直接出现的变体。messages.ts 负责「构造」与「变换」,而不是网络传输。许多字段(isMeta、isVisibleInTranscriptOnly、origin)只影响 UI 或落盘策略,不会发给模型。读源码时要分清:哪些函数产出给 API 的 payload,哪些只服务 Ink 渲染。

合成消息与 HFI 防护

SYNTHETIC_TOOL_RESULT_PLACEHOLDER 与 SYNTHETIC_MODEL 明确标记「非真实模型输出」。注释写明 HFI 提交必须拒绝含占位 tool_result 的轨迹,避免污染训练数据。isSyntheticMessage 用固定文案集合判断用户侧合成内容,使 UI 可以折叠或隐藏这类条目,而不把它们当作真实用户意图。

normalizeMessages 与 UUID 链

当 assistant 或 user 一条消息含多个 content block 时,normalizeMessages 将其摊平为多条「每块一条」的 NormalizedMessage。若已进入 isNewChain 状态,后续消息用 deriveUUID(parent, index) 生成确定性子 UUID,避免重复键并维持 parentUuid 顺序。sessionStorage 的 insertMessageChain 依赖这条链;normalize 阶段弄错 UUID,resume 时会出现孤儿消息或 API 拒绝 duplicate tool_use_id。

建议学习步骤

  1. 阅读源码块 A:中断与拒绝常量
  2. 阅读源码块 B:分类器拒绝文案构造
  3. 阅读源码块 C:createAssistantMessage / createUserMessage
  4. 阅读源码块 D:normalizeMessages 与 deriveUUID
  5. 阅读源码块 E:ensureToolResultPairing 入口逻辑
  6. 在源码树打开 utils/messages.ts 对照行号

常见误区

注意

不要把 utils/hooks.ts 的 Hook 附件与 messages.ts 的 Message 类型混为一谈

注意

REJECT_MESSAGE 与 SUBAGENT_REJECT_MESSAGE 面向不同调用方,子 Agent 场景用后者

注意

extractTag 用于解析本地命令 XML 标签,不是 HTML 安全过滤器

在架构中的位置

Claude Code 的数据流可概括为:

API 流式事件 → query 组装 AssistantMessage
用户输入 / 工具结果 → createUserMessage / tool_result blocks
REPL 渲染前 → normalizeMessages(可选)
发 API 前 → ensureToolResultPairing + normalizeMessagesForAPI(其他模块)
落盘前 → sessionStorage.cleanMessagesForLogging

messages.ts 位于「业务消息对象」的中心:hooks、permissions、tools 都 import 它的常量或工厂函数。改一条拒绝文案可能影响模型续写行为,属于高影响改动。

中断、取消与权限拒绝常量

用户按 Esc 中断、在权限弹窗点 Deny、或在 auto/dontAsk 模式被策略拒绝时,模型看到的不是空字符串,而是精心编写的英文指令,要求模型 STOP 并等待用户指示。

阅读要点:

  1. INTERRUPT_MESSAGE 与 INTERRUPT_MESSAGE_FOR_TOOL_USE 区分「整轮中断」与「为 tool_use 中断」
  2. REJECT_MESSAGE* 与 SUBAGENT_REJECT_MESSAGE* 文案不同:子 Agent 不能假设用户会在同一 REPL 里操作
  3. DENIAL_WORKAROUND_GUIDANCE 被 AUTO_REJECT、DONT_ASK_REJECT、buildYoloRejectionMessage 复用,统一「可尝试合理替代工具但禁止恶意绕过」的边界
  4. SYNTHETIC_TOOL_RESULT_PLACEHOLDER 仅在内部修复配对时出现,对外提交必须过滤

权限模块的 DONT_ASK_REJECT_MESSAGE 直接引用本文件导出,说明文案是跨模块契约。

源码引用: src/utils/messages.ts · 第 207–247 行(共 5513 行)

 207| export const INTERRUPT_MESSAGE = '[Request interrupted by user]'
 208| export const INTERRUPT_MESSAGE_FOR_TOOL_USE =
 209|   '[Request interrupted by user for tool use]'
 210| export const CANCEL_MESSAGE =
 211|   "The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed."
 212| export const REJECT_MESSAGE =
 213|   "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
 214| export const REJECT_MESSAGE_WITH_REASON_PREFIX =
 215|   "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\n"
 216| export const SUBAGENT_REJECT_MESSAGE =
 217|   'Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task.'
 218| export const SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX =
 219|   'Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:\n'
 220| export const PLAN_REJECTION_PREFIX =
 221|   'The agent proposed a plan that was rejected by the user. The user chose to stay in plan mode rather than proceed with implementation.\n\nRejected plan:\n'
 222| 
 223| /**
 224|  * Shared guidance for permission denials, instructing the model on appropriate workarounds.
 225|  */
 226| export const DENIAL_WORKAROUND_GUIDANCE =
 227|   `IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, ` +
 228|   `e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, ` +
 229|   `e.g. do not use your ability to run tests to execute non-test actions. ` +
 230|   `You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. ` +
 231|   `If you believe this capability is essential to complete the user's request, STOP and explain to the user ` +
 232|   `what you were trying to do and why you need this permission. Let the user decide how to proceed.`
 233| 
 234| export function AUTO_REJECT_MESSAGE(toolName: string): string {
 235|   return `Permission to use ${toolName} has been denied. ${DENIAL_WORKAROUND_GUIDANCE}`
 236| }
 237| export function DONT_ASK_REJECT_MESSAGE(toolName: string): string {
 238|   return `Permission to use ${toolName} has been denied because Claude Code is running in don't ask mode. ${DENIAL_WORKAROUND_GUIDANCE}`
 239| }
 240| export const NO_RESPONSE_REQUESTED = 'No response requested.'
 241| 
 242| // Synthetic tool_result content inserted by ensureToolResultPairing when a
 243| // tool_use block has no matching tool_result. Exported so HFI submission can
 244| // reject any payload containing it — placeholder satisfies pairing structurally
 245| // but the content is fake, which poisons training data if submitted.
 246| export const SYNTHETIC_TOOL_RESULT_PLACEHOLDER =
 247|   '[Tool result missing due to internal error]'

Auto 模式分类器拒绝文案

当 TRANSCRIPT_CLASSIFIER 开启且权限模式为 auto 时,Bash 等敏感操作可能由分类器 deny 而非用户点击。UI 需要短摘要,因此 isClassifierDenial 检测固定前缀 Permission for this action has been denied. Reason:。

buildYoloRejectionMessage 在 reason 后追加:

  • 可继续其他不依赖该动作的任务
  • DENIAL_WORKAROUND_GUIDANCE
  • 根据 feature gate 提示用户添加 Bash(prompt:...) 规则

buildClassifierUnavailableMessage 处理分类器模型暂时不可用:要求等待重试,并明确只读操作不需要分类器。这对 headless/async Agent 的降级路径尤为重要——避免 Agent 误以为永久 ban 某工具。

源码引用: src/utils/messages.ts · 第 249–298 行(共 5513 行)

 249| // Prefix used by UI to detect classifier denials and render them concisely
 250| const AUTO_MODE_REJECTION_PREFIX =
 251|   'Permission for this action has been denied. Reason: '
 252| 
 253| /**
 254|  * Check if a tool result message is a classifier denial.
 255|  * Used by the UI to render a short summary instead of the full message.
 256|  */
 257| export function isClassifierDenial(content: string): boolean {
 258|   return content.startsWith(AUTO_MODE_REJECTION_PREFIX)
 259| }
 260| 
 261| /**
 262|  * Build a rejection message for auto mode classifier denials.
 263|  * Encourages continuing with other tasks and suggests permission rules.
 264|  *
 265|  * @param reason - The classifier's reason for denying the action
 266|  */
 267| export function buildYoloRejectionMessage(reason: string): string {
 268|   const prefix = AUTO_MODE_REJECTION_PREFIX
 269| 
 270|   const ruleHint = feature('BASH_CLASSIFIER')
 271|     ? `To allow this type of action in the future, the user can add a permission rule like ` +
 272|       `Bash(prompt: <description of allowed action>) to their settings. ` +
 273|       `At the end of your session, recommend what permission rules to add so you don't get blocked again.`
 274|     : `To allow this type of action in the future, the user can add a Bash permission rule to their settings.`
 275| 
 276|   return (
 277|     `${prefix}${reason}. ` +
 278|     `If you have other tasks that don't depend on this action, continue working on those. ` +
 279|     `${DENIAL_WORKAROUND_GUIDANCE} ` +
 280|     ruleHint
 281|   )
 282| }
 283| 
 284| /**
 285|  * Build a message for when the auto mode classifier is temporarily unavailable.
 286|  * Tells the agent to wait and retry, and suggests working on other tasks.
 287|  */
 288| export function buildClassifierUnavailableMessage(
 289|   toolName: string,
 290|   classifierModel: string,
 291| ): string {
 292|   return (
 293|     `${classifierModel} is temporarily unavailable, so auto mode cannot determine the safety of ${toolName} right now. ` +
 294|     `Wait briefly and then try this action again. ` +
 295|     `If it keeps failing, continue with other tasks that don't require this action and come back to it later. ` +
 296|     `Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used.`
 297|   )
 298| }

源码引用: src/utils/messages.ts · 第 300–318 行(共 5513 行)

 300| export const SYNTHETIC_MODEL = '<synthetic>'
 301| 
 302| export const SYNTHETIC_MESSAGES = new Set([
 303|   INTERRUPT_MESSAGE,
 304|   INTERRUPT_MESSAGE_FOR_TOOL_USE,
 305|   CANCEL_MESSAGE,
 306|   REJECT_MESSAGE,
 307|   NO_RESPONSE_REQUESTED,
 308| ])
 309| 
 310| export function isSyntheticMessage(message: Message): boolean {
 311|   return (
 312|     message.type !== 'progress' &&
 313|     message.type !== 'attachment' &&
 314|     message.type !== 'system' &&
 315|     Array.isArray(message.message.content) &&
 316|     message.message.content[0]?.type === 'text' &&
 317|     SYNTHETIC_MESSAGES.has(message.message.content[0].text)
 318|   )

合成消息判定与助手轮次查询

SYNTHETIC_MESSAGES 集合收录中断、取消、拒绝等固定字符串。isSyntheticMessage 要求:非 progress/attachment/system,且首块为 text 并命中集合。

getLastAssistantMessage 使用 findLast 从尾部扫描——注释强调 REPL 每次渲染都会调用,对大数组 O(n) 不可接受。hasToolCallsInLastAssistantTurn 从后向前找最近 assistant,判断 content 是否含 tool_use,用于 UI 状态(例如是否显示工具进度)。

工程练习: 在调试 REPL 卡死时,先打印 messages 长度,再单步 getLastAssistantMessage,确认是否误把 attachment 当成 assistant。

源码引用: src/utils/messages.ts · 第 310–349 行(共 5513 行)

 310| export function isSyntheticMessage(message: Message): boolean {
 311|   return (
 312|     message.type !== 'progress' &&
 313|     message.type !== 'attachment' &&
 314|     message.type !== 'system' &&
 315|     Array.isArray(message.message.content) &&
 316|     message.message.content[0]?.type === 'text' &&
 317|     SYNTHETIC_MESSAGES.has(message.message.content[0].text)
 318|   )
 319| }
 320| 
 321| function isSyntheticApiErrorMessage(
 322|   message: Message,
 323| ): message is AssistantMessage & { isApiErrorMessage: true } {
 324|   return (
 325|     message.type === 'assistant' &&
 326|     message.isApiErrorMessage === true &&
 327|     message.message.model === SYNTHETIC_MODEL
 328|   )
 329| }
 330| 
 331| export function getLastAssistantMessage(
 332|   messages: Message[],
 333| ): AssistantMessage | undefined {
 334|   // findLast exits early from the end — much faster than filter + last for
 335|   // large message arrays (called on every REPL render via useFeedbackSurvey).
 336|   return messages.findLast(
 337|     (msg): msg is AssistantMessage => msg.type === 'assistant',
 338|   )
 339| }
 340| 
 341| export function hasToolCallsInLastAssistantTurn(messages: Message[]): boolean {
 342|   for (let i = messages.length - 1; i >= 0; i--) {
 343|     const message = messages[i]
 344|     if (message && message.type === 'assistant') {
 345|       const assistantMessage = message as AssistantMessage
 346|       const content = assistantMessage.message.content
 347|       if (Array.isArray(content)) {
 348|         return content.some(block => block.type === 'tool_use')
 349|       }

createAssistantMessage 与 createUserMessage

工厂函数统一处理空内容与元数据:

Assistant 侧:

  • 字符串 content 包装为单 text block;空串替换为 NO_CONTENT_MESSAGE
  • createAssistantAPIErrorMessage 标记 isApiErrorMessage,模型字段用 SYNTHETIC_MODEL

User 侧:

  • createUserMessage 支持 isMeta、isVisibleInTranscriptOnly、toolUseResult、mcpMeta、permissionMode、origin 等
  • uuid 默认 randomUUID();可传入以支持 resume 复现
  • sourceToolAssistantUUID 把 tool_result 挂回对应 assistant 的 tool_use

prepareUserContent 把纯文本与「前置 block」(图片等)合并为 ContentBlockParam 数组。createUserInterruptionMessage 根据 toolUse 标志选择 INTERRUPT 常量。

这些字段直接影响 sessionStorage 是否把消息当作 transcript 成员(见 session-storage 章 isTranscriptMessage)。

源码引用: src/utils/messages.ts · 第 411–523 行(共 5513 行)

 411| export function createAssistantMessage({
 412|   content,
 413|   usage,
 414|   isVirtual,
 415| }: {
 416|   content: string | BetaContentBlock[]
 417|   usage?: Usage
 418|   isVirtual?: true
 419| }): AssistantMessage {
 420|   return baseCreateAssistantMessage({
 421|     content:
 422|       typeof content === 'string'
 423|         ? [
 424|             {
 425|               type: 'text' as const,
 426|               text: content === '' ? NO_CONTENT_MESSAGE : content,
 427|             } as BetaContentBlock, // NOTE: citations field is not supported in Bedrock API
 428|           ]
 429|         : content,
 430|     usage,
 431|     isVirtual,
 432|   })
 433| }
 434| 
 435| export function createAssistantAPIErrorMessage({
 436|   content,
 437|   apiError,
 438|   error,
 439|   errorDetails,
 440| }: {
 441|   content: string
 442|   apiError?: AssistantMessage['apiError']
 443|   error?: SDKAssistantMessageError
 444|   errorDetails?: string
 445| }): AssistantMessage {
 446|   return baseCreateAssistantMessage({
 447|     content: [
 448|       {
 449|         type: 'text' as const,
 450|         text: content === '' ? NO_CONTENT_MESSAGE : content,
 451|       } as BetaContentBlock, // NOTE: citations field is not supported in Bedrock API
 452|     ],
 453|     isApiErrorMessage: true,
 454|     apiError,
 455|     error,
 456|     errorDetails,
 457|   })
 458| }
 459| 
 460| export function createUserMessage({
 461|   content,
 462|   isMeta,
 463|   isVisibleInTranscriptOnly,
 464|   isVirtual,
 465|   isCompactSummary,
 466|   summarizeMetadata,
 467|   toolUseResult,
 468|   mcpMeta,
 469|   uuid,
 470|   timestamp,
 471|   imagePasteIds,
 472|   sourceToolAssistantUUID,
 473|   permissionMode,
 474|   origin,
 475| }: {
 476|   content: string | ContentBlockParam[]
 477|   isMeta?: true
 478|   isVisibleInTranscriptOnly?: true
 479|   isVirtual?: true
 480|   isCompactSummary?: true
 481|   toolUseResult?: unknown // Matches tool's `Output` type
 482|   /** MCP protocol metadata to pass through to SDK consumers (never sent to model) */
 483|   mcpMeta?: {
 484|     _meta?: Record<string, unknown>
 485|     structuredContent?: Record<string, unknown>
 486|   }
 487|   uuid?: UUID | string
 488|   timestamp?: string
 489|   imagePasteIds?: number[]
 490|   // For tool_result messages: the UUID of the assistant message containing the matching tool_use
 491|   sourceToolAssistantUUID?: UUID
 492|   // Permission mode when message was sent (for rewind restoration)
 493|   permissionMode?: PermissionMode
 494|   summarizeMetadata?: {
 495|     messagesSummarized: number
 496|     userContext?: string
 497|     direction?: PartialCompactDirection
 498|   }
 499|   // Provenance of this message. undefined = human (keyboard).
 500|   origin?: MessageOrigin
 501| }): UserMessage {
 502|   const m: UserMessage = {
 503|     type: 'user',
 504|     message: {
 505|       role: 'user',
 506|       content: content || NO_CONTENT_MESSAGE, // Make sure we don't send empty messages
 507|     },
 508|     isMeta,
 509|     isVisibleInTranscriptOnly,
 510|     isVirtual,
 511|     isCompactSummary,
 512|     summarizeMetadata,
 513|     uuid: (uuid as UUID | undefined) || randomUUID(),
 514|     timestamp: timestamp ?? new Date().toISOString(),
 515|     toolUseResult,
 516|     mcpMeta,
 517|     imagePasteIds,
 518|     sourceToolAssistantUUID,
 519|     permissionMode,
 520|     origin,
 521|   }
 522|   return m
 523| }

源码引用: src/utils/messages.ts · 第 525–543 行(共 5513 行)

 525| export function prepareUserContent({
 526|   inputString,
 527|   precedingInputBlocks,
 528| }: {
 529|   inputString: string
 530|   precedingInputBlocks: ContentBlockParam[]
 531| }): string | ContentBlockParam[] {
 532|   if (precedingInputBlocks.length === 0) {
 533|     return inputString
 534|   }
 535| 
 536|   return [
 537|     ...precedingInputBlocks,
 538|     {
 539|       text: inputString,
 540|       type: 'text',
 541|     },
 542|   ]
 543| }

extractTag 与本地命令 XML

Claude Code 在本地命令、计划模式等场景用 XML 风格标签包裹结构化数据(见 constants/xml.ts)。extractTag(html, tagName) 用正则提取 配对闭合标签 的内部文本,并处理同名标签嵌套(depth 计数)。

注意:

  • 参数名虽叫 html,实际是任意字符串
  • 失败返回 null,调用方需兜底
  • 与 stripIdeContextTags(displayTags.ts)配合使用,避免 IDE 上下文泄漏到模型

sessionStorage 的 extractTag import 用于从 transcript 提取首条用户 prompt 或 compact 边界信息,说明标签解析是会话恢复链路的一环。

源码引用: src/utils/messages.ts · 第 622–687 行(共 5513 行)

 622| export function createToolResultStopMessage(
 623|   toolUseID: string,
 624| ): ToolResultBlockParam {
 625|   return {
 626|     type: 'tool_result',
 627|     content: CANCEL_MESSAGE,
 628|     is_error: true,
 629|     tool_use_id: toolUseID,
 630|   }
 631| }
 632| 
 633| export function extractTag(html: string, tagName: string): string | null {
 634|   if (!html.trim() || !tagName.trim()) {
 635|     return null
 636|   }
 637| 
 638|   const escapedTag = escapeRegExp(tagName)
 639| 
 640|   // Create regex pattern that handles:
 641|   // 1. Self-closing tags
 642|   // 2. Tags with attributes
 643|   // 3. Nested tags of the same type
 644|   // 4. Multiline content
 645|   const pattern = new RegExp(
 646|     `<${escapedTag}(?:\\s+[^>]*)?>` + // Opening tag with optional attributes
 647|       '([\\s\\S]*?)' + // Content (non-greedy match)
 648|       `<\\/${escapedTag}>`, // Closing tag
 649|     'gi',
 650|   )
 651| 
 652|   let match
 653|   let depth = 0
 654|   let lastIndex = 0
 655|   const openingTag = new RegExp(`<${escapedTag}(?:\\s+[^>]*?)?>`, 'gi')
 656|   const closingTag = new RegExp(`<\\/${escapedTag}>`, 'gi')
 657| 
 658|   while ((match = pattern.exec(html)) !== null) {
 659|     // Check for nested tags
 660|     const content = match[1]
 661|     const beforeMatch = html.slice(lastIndex, match.index)
 662| 
 663|     // Reset depth counter
 664|     depth = 0
 665| 
 666|     // Count opening tags before this match
 667|     openingTag.lastIndex = 0
 668|     while (openingTag.exec(beforeMatch) !== null) {
 669|       depth++
 670|     }
 671| 
 672|     // Count closing tags before this match
 673|     closingTag.lastIndex = 0
 674|     while (closingTag.exec(beforeMatch) !== null) {
 675|       depth--
 676|     }
 677| 
 678|     // Only include content if we're at the correct nesting level
 679|     if (depth === 0 && content) {
 680|       return content
 681|     }
 682| 
 683|     lastIndex = match.index + match[0].length
 684|   }
 685| 
 686|   return null
 687| }

normalizeMessages:多块摊平与 deriveUUID

normalizeMessages 是把「逻辑上一条」变成「物理上多条」的关键步骤:

  1. 遇到 assistant 且 content.length > 1,设 isNewChain = true
  2. 每条摊平消息只保留一个 content block
  3. isNewChain 为真时,uuid = deriveUUID(原 uuid, index);否则保留原 uuid
  4. user 字符串 content 先转为单元素 text 数组再摊平
  5. 图片块单独追踪 imagePasteIds 下标

为何重要: API 与部分分析器假设「一条 message 一个 tool_use」。合并块若不拆分,会导致 tool_use_id 冲突或权限弹窗无法对应单一工具。deriveUUID 用 parent 前 24 字符 + index 的 12 位十六进制,保证确定性,便于测试对比。

attachment、progress、system 原样透传,不参与摊平。

源码引用: src/utils/messages.ts · 第 722–819 行(共 5513 行)

 722| // Deterministic UUID derivation. Produces a stable UUID-shaped string from a
 723| // parent UUID + content block index so that the same input always produces the
 724| // same key across calls. Used by normalizeMessages and synthetic message creation.
 725| export function deriveUUID(parentUUID: UUID, index: number): UUID {
 726|   const hex = index.toString(16).padStart(12, '0')
 727|   return `${parentUUID.slice(0, 24)}${hex}` as UUID
 728| }
 729| 
 730| // Split messages, so each content block gets its own message
 731| export function normalizeMessages(
 732|   messages: AssistantMessage[],
 733| ): NormalizedAssistantMessage[]
 734| export function normalizeMessages(
 735|   messages: UserMessage[],
 736| ): NormalizedUserMessage[]
 737| export function normalizeMessages(
 738|   messages: (AssistantMessage | UserMessage)[],
 739| ): (NormalizedAssistantMessage | NormalizedUserMessage)[]
 740| export function normalizeMessages(messages: Message[]): NormalizedMessage[]
 741| export function normalizeMessages(messages: Message[]): NormalizedMessage[] {
 742|   // isNewChain tracks whether we need to generate new UUIDs for messages when normalizing.
 743|   // When a message has multiple content blocks, we split it into multiple messages,
 744|   // each with a single content block. When this happens, we need to generate new UUIDs
 745|   // for all subsequent messages to maintain proper ordering and prevent duplicate UUIDs.
 746|   // This flag is set to true once we encounter a message with multiple content blocks,
 747|   // and remains true for all subsequent messages in the normalization process.
 748|   let isNewChain = false
 749|   return messages.flatMap(message => {
 750|     switch (message.type) {
 751|       case 'assistant': {
 752|         isNewChain = isNewChain || message.message.content.length > 1
 753|         return message.message.content.map((_, index) => {
 754|           const uuid = isNewChain
 755|             ? deriveUUID(message.uuid, index)
 756|             : message.uuid
 757|           return {
 758|             type: 'assistant' as const,
 759|             timestamp: message.timestamp,
 760|             message: {
 761|               ...message.message,
 762|               content: [_],
 763|               context_management: message.message.context_management ?? null,
 764|             },
 765|             isMeta: message.isMeta,
 766|             isVirtual: message.isVirtual,
 767|             requestId: message.requestId,
 768|             uuid,
 769|             error: message.error,
 770|             isApiErrorMessage: message.isApiErrorMessage,
 771|             advisorModel: message.advisorModel,
 772|           } as NormalizedAssistantMessage
 773|         })
 774|       }
 775|       case 'attachment':
 776|         return [message]
 777|       case 'progress':
 778|         return [message]
 779|       case 'system':
 780|         return [message]
 781|       case 'user': {
 782|         if (typeof message.message.content === 'string') {
 783|           const uuid = isNewChain ? deriveUUID(message.uuid, 0) : message.uuid
 784|           return [
 785|             {
 786|               ...message,
 787|               uuid,
 788|               message: {
 789|                 ...message.message,
 790|                 content: [{ type: 'text', text: message.message.content }],
 791|               },
 792|             } as NormalizedMessage,
 793|           ]
 794|         }
 795|         isNewChain = isNewChain || message.message.content.length > 1
 796|         let imageIndex = 0
 797|         return message.message.content.map((_, index) => {
 798|           const isImage = _.type === 'image'
 799|           // For image content blocks, extract just the ID for this image
 800|           const imageId =
 801|             isImage && message.imagePasteIds
 802|               ? message.imagePasteIds[imageIndex]
 803|               : undefined
 804|           if (isImage) imageIndex++
 805|           return {
 806|             ...createUserMessage({
 807|               content: [_],
 808|               toolUseResult: message.toolUseResult,
 809|               mcpMeta: message.mcpMeta,
 810|               isMeta: message.isMeta,
 811|               isVisibleInTranscriptOnly: message.isVisibleInTranscriptOnly,
 812|               isVirtual: message.isVirtual,
 813|               timestamp: message.timestamp,
 814|               imagePasteIds: imageId !== undefined ? [imageId] : undefined,
 815|               origin: message.origin,
 816|             }),
 817|             uuid: isNewChain ? deriveUUID(message.uuid, index) : message.uuid,
 818|           } as NormalizedMessage
 819|         })

ensureToolResultPairing:配对修复与 strict 模式

发往 API 前必须满足:每个 tool_use 有对应 tool_result,且 tool_use_id 全局唯一。ensureToolResultPairing 遍历 user/assistant 消息:

  • 维护 allSeenToolUseIds 跨消息去重(修复 CC-1212 类死锁)
  • assistant 后若无 user tool_result,可插入 SYNTHETIC 占位(非 strict)
  • resume 时 transcript 首条若是孤儿 tool_result,会 strip 并可能保留占位 user
  • strict 模式下配对失败会抛错,避免污染 HFI 轨迹

注释明确:占位内容绝不能进入训练提交。读此函数时应对照 query 里调用点,理解「修复」与「失败」的边界。

源码引用: src/utils/messages.ts · 第 5133–5185 行(共 5513 行)

5133| export function ensureToolResultPairing(
5134|   messages: (UserMessage | AssistantMessage)[],
5135| ): (UserMessage | AssistantMessage)[] {
5136|   const result: (UserMessage | AssistantMessage)[] = []
5137|   let repaired = false
5138| 
5139|   // Cross-message tool_use ID tracking. The per-message seenToolUseIds below
5140|   // only caught duplicates within a single assistant's content array (the
5141|   // normalizeMessagesForAPI-merged case). When two assistants with DIFFERENT
5142|   // message.id carry the same tool_use ID — e.g. orphan handler re-pushed an
5143|   // assistant already present in mutableMessages with a fresh message.id, or
5144|   // normalizeMessagesForAPI's backward walk broke on an intervening user
5145|   // message — the dup lived in separate result entries and the API rejected
5146|   // with "tool_use ids must be unique", deadlocking the session (CC-1212).
5147|   const allSeenToolUseIds = new Set<string>()
5148| 
5149|   for (let i = 0; i < messages.length; i++) {
5150|     const msg = messages[i]!
5151| 
5152|     if (msg.type !== 'assistant') {
5153|       // A user message with tool_result blocks but NO preceding assistant
5154|       // message in the output has orphaned tool_results. The assistant
5155|       // lookahead below only validates assistant→user adjacency; it never
5156|       // sees user messages at index 0 or user messages preceded by another
5157|       // user. This happens on resume when the transcript starts mid-turn
5158|       // (e.g. messages[0] is a tool_result whose assistant pair was dropped
5159|       // by earlier compaction — API rejects with "messages.0.content:
5160|       // unexpected tool_use_id").
5161|       if (
5162|         msg.type === 'user' &&
5163|         Array.isArray(msg.message.content) &&
5164|         result.at(-1)?.type !== 'assistant'
5165|       ) {
5166|         const stripped = msg.message.content.filter(
5167|           block =>
5168|             !(
5169|               typeof block === 'object' &&
5170|               'type' in block &&
5171|               block.type === 'tool_result'
5172|             ),
5173|         )
5174|         if (stripped.length !== msg.message.content.length) {
5175|           repaired = true
5176|           // If stripping emptied the message and nothing has been pushed yet,
5177|           // keep a placeholder so the payload still starts with a user
5178|           // message (normalizeMessagesForAPI runs before us, so messages[1]
5179|           // is an assistant — dropping messages[0] entirely would yield a
5180|           // payload starting with assistant, a different 400).
5181|           const content =
5182|             stripped.length > 0
5183|               ? stripped
5184|               : result.length === 0
5185|                 ? [

源码目录与关联文件

强关联:utils/attachments.ts(Hook 附件类型)、types/message.ts(联合类型定义)、query.ts(消费 normalize 结果)。点击 messages.ts 跳回本章源码块。

动手练习

  1. 在 REPL 触发一次权限拒绝,复制 tool_result 文本,判断命中 REJECT 还是 buildYoloRejectionMessage
  2. 用调试器在 createUserMessage 打断点,观察 isMeta 与 isVisibleInTranscriptOnly 何时为 true
  3. 阅读 normalizeMessages 单元测试(若有)或本地构造含双 tool_use 的 assistant,观察 deriveUUID 输出
  4. 对照 ensureToolResultPairing 注释中的 CC-1212,理解为何需要 allSeenToolUseIds

本章小结与延伸

messages.ts = 对话结构的工厂与修理工。下一章建议 session-storage,理解这些 Message 如何写入 JSONL。 继续学习:

  • session-storage
  • permissions
Prev
模块: utils
Next
session-storage · JSONL 会话持久化