本章总览
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。
建议学习步骤
- 阅读源码块 A:中断与拒绝常量
- 阅读源码块 B:分类器拒绝文案构造
- 阅读源码块 C:createAssistantMessage / createUserMessage
- 阅读源码块 D:normalizeMessages 与 deriveUUID
- 阅读源码块 E:ensureToolResultPairing 入口逻辑
- 在源码树打开 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 并等待用户指示。
阅读要点:
INTERRUPT_MESSAGE与INTERRUPT_MESSAGE_FOR_TOOL_USE区分「整轮中断」与「为 tool_use 中断」REJECT_MESSAGE*与SUBAGENT_REJECT_MESSAGE*文案不同:子 Agent 不能假设用户会在同一 REPL 里操作DENIAL_WORKAROUND_GUIDANCE被 AUTO_REJECT、DONT_ASK_REJECT、buildYoloRejectionMessage 复用,统一「可尝试合理替代工具但禁止恶意绕过」的边界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 是把「逻辑上一条」变成「物理上多条」的关键步骤:
- 遇到 assistant 且 content.length > 1,设 isNewChain = true
- 每条摊平消息只保留一个 content block
- isNewChain 为真时,uuid = deriveUUID(原 uuid, index);否则保留原 uuid
- user 字符串 content 先转为单元素 text 数组再摊平
- 图片块单独追踪 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 跳回本章源码块。
动手练习
- 在 REPL 触发一次权限拒绝,复制 tool_result 文本,判断命中 REJECT 还是 buildYoloRejectionMessage
- 用调试器在 createUserMessage 打断点,观察 isMeta 与 isVisibleInTranscriptOnly 何时为 true
- 阅读 normalizeMessages 单元测试(若有)或本地构造含双 tool_use 的 assistant,观察 deriveUUID 输出
- 对照 ensureToolResultPairing 注释中的 CC-1212,理解为何需要 allSeenToolUseIds
本章小结与延伸
messages.ts = 对话结构的工厂与修理工。下一章建议 session-storage,理解这些 Message 如何写入 JSONL。 继续学习: