PR #147:让 Claude 流式片段和最终消息共享同一个内容身份

Buffin · main...fix/claude-native-message-id · 2026-07-16 · 已合并 · 自包含,读完即弃

7 commits
6 文件
+925 / −59
60% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1先说结论

Claude 的同一条原生消息会先以 stream_event 逐块输出,再以一个或多个 assistant envelope 给出权威完成内容。以前 Buffin 按“第几个 assistant envelope + envelope 内部下标”生成 itemId;当最终 envelope 拆分、重排或修正文案时,delta 与 completion 会落到不同身份上。

这个 PR 把原生 message.id 提升为消息分组键。流开始时创建组,内容块出现时分配 Buffin 自己的 m1b0 一类 ID,最终 envelope 再按角色和已累积文本认领未完成块。结果是流式文本、最终文本、持久化回放和重复投递都围绕同一个内容身份收敛。

最重要的边界:PR 名说“保留 native message id”,并不是把 Claude 的 UUID 直接暴露成 Buffin 的 itemId。原生 ID 只负责把 envelope 归入同一消息组;公开事件仍使用短小、run-local 的 m{组序号}b{块序号}

2状态先行:从一个计数器变成消息组

旧状态只有 messageOrdinal,它假定每个最终 assistant envelope 就是一条新消息。新状态显式保存“原生消息 → 内容块”的关系,以及当前生命周期、最近关闭的流、无生命周期回退组和重放标记。

apps/daemon/src/agent/providers/claude/translator.ts核心状态形状(节选)
type ClaudeContentBlock = {
  itemId: string
  role: 'assistant' | 'reasoning'
  accumulatedText: string
  completed: boolean
}

type ClaudeMessageGroup = {
  ordinal: number
  blocksByIndex: Map<number | string, ClaudeContentBlock>
  blocksInOrder: ClaudeContentBlock[]
  streamStarted: boolean
  streamClosed: boolean
}

export type ClaudeTranslationState = {
  messageGroups: Map<string, ClaudeMessageGroup>
  assistantEnvelopeGroups: Map<string, ClaudeMessageGroup>
  activeGroup?: ClaudeMessageGroup | undefined
  latestStreamGroup?: ClaudeMessageGroup | undefined
  replayingActiveStream: boolean
  fallbackGroup?: ClaudeMessageGroup | undefined
  allowStandaloneDeltas: boolean
  streamLifecycleSeen: boolean
  nextGroupOrdinal: number
}

blocksByIndex 服务流式阶段:provider index 能快速找到已经声明的块。blocksInOrder 服务完成阶段:最终 envelope 的本地下标不可信,所以要在原始分配顺序里寻找仍未完成的同角色块。两种索引面向两个不同协议阶段。

3旅程 A:一条 Claude 消息如何保持同一个身份

这条旅程从 Claude 的 message_start 开始,经过块声明和 token delta,最后落到一个或多个 authoritative assistant envelope。走完它,就能知道 itemId 在哪里生成、何时复用、哪些输入会被抑制。

全景 · 涉及 4 个关键环节
建立消息组
beginMessageStream
声明并累积块
streamBlockForDelta
完成内容认领
completionBlockForText
SessionSink 持久化与回放

A.1旧算法错位在“最终 envelope 的本地下标”

main 上,translator 收到一个 assistant envelope 就读取当前消息序号,并用 envelope 的数组下标生成 ID,随后立即把消息序号加一。这个算法对“一个流对应一个完整 envelope,而且内容顺序不变”的输入成立。

main: apps/daemon/src/agent/providers/claude/translator.ts修复前的真实代码
const messageOrdinal = state.messageOrdinal
for (const [index, item] of message.content.entries()) {
  const itemId = contentBlockItemId(messageOrdinal, index)
  const event = assistantContentEvent(
    item,
    parsed.is_plan === true,
    itemId,
    occurredAt,
    raw,
  )
  if (event) events.push(event)
}
state.messageOrdinal += 1

Claude 可以把 reasoning 和 text 拆成两个 envelope,而每个 envelope 都从 index 0 开始;也可以在最终内容里改变顺序或补标点。此时“envelope index”不再等于“流中 content block 的身份”,于是同一段内容的 delta 和 completion 会拥有不同 itemId

以前
stream index 0/1 直接生成 m1b0/m1b1
第一个 final envelope 从 index 0 重新生成 m1b0
第二个 final envelope 被当成新消息,生成 m2b0
现在
原生 message.id 选择同一个消息组
块首次出现时分配 m1b0/m1b1
所有 final envelope 都回到该组,认领已有块

A.2生命周期先建立 provider message group

message_start 是分组的入口。translator 读取非空原生 ID,通过 messageGroupForId 取回或创建组,并把它同时记为当前活动组和最近一个有生命周期的组。后者用于最终 envelope 没带 ID 时的关联。

apps/daemon/src/agent/providers/claude/translator.ts生命周期开始与关闭
function beginMessageStream(
  state: ClaudeTranslationState,
  event: Record<string, unknown>,
): void {
  state.streamLifecycleSeen = true
  const messageId = readProviderMessageId(getRecord(event.message).id)
  if (!messageId) return

  const group = messageGroupForId(state, messageId)
  state.replayingActiveStream = group.streamStarted
  group.streamStarted = true
  if (!state.replayingActiveStream) group.streamClosed = false
  state.activeGroup = group
  state.latestStreamGroup = group
}

function closeMessageStream(state: ClaudeTranslationState): void {
  state.streamLifecycleSeen = true
  if (state.activeGroup && !state.replayingActiveStream) {
    state.activeGroup.streamClosed = true
  }
  state.activeGroup = undefined
  state.replayingActiveStream = false
}

同一个原生 ID 再次触发 message_start 时,streamStarted 已经为真,因此当前生命周期被标为 replay。它仍然能走完整个协议序列,但不会再次追加文本。

A.3delta 只进入已声明且仍开放的块

content_block_start 先把 provider index、角色和新生成的 itemId 放入活动组。随后到来的 delta 必须命中同一个 index,角色一致,并且块尚未完成。未知 index、已关闭流和重放流都会返回 null

apps/daemon/src/agent/providers/claude/translator.tsdelta 与块的关联
function streamBlockForDelta(
  state: ClaudeTranslationState,
  role: ClaudeContentBlock['role'],
  index: unknown,
  text: string,
): ClaudeContentBlock | null {
  const streamIndex = readStreamIndex(index)
  if (streamIndex === undefined) return null

  const activeGroup = state.activeGroup
  const group = activeGroup ?? standaloneDeltaGroup(state)
  if (!group || state.replayingActiveStream || group.streamClosed) return null

  const block = group.blocksByIndex.get(streamIndex)
  if (block) {
    if (block.role !== role || block.completed) return null
    block.accumulatedText += text
    return block
  }
  if (activeGroup) return null

  const fallbackBlock = createContentBlock(group, role)
  fallbackBlock.accumulatedText = text
  group.blocksByIndex.set(streamIndex, fallbackBlock)
  return fallbackBlock
}

这里的严格门槛把生命周期当成正常路径的协议证据。只有 runner 明确开启 standalone 模式,而且整次交互尚未见过任何生命周期时,delta 才能自己创建 fallback block。

A.4最终 completion 反向认领未完成块

最终 assistant envelope 先按原生 ID 找组;没有 ID 时,优先找当前或最近的未完成流。真正选择块时不再看 envelope 的本地下标,而是只看同角色、尚未完成的候选块。

如果某个候选块累积出的文本与最终文本完全相同,它优先被认领。这解决同角色块在 final envelope 中换序的问题。如果最终文本被 Claude 修正过,找不到精确相等项,就按原始块分配顺序认领第一个候选;completion 仍沿用流式 itemId,只是文本更新为权威版本。

apps/daemon/src/agent/providers/claude/translator.ts完成内容的匹配策略
function completionBlockForText(
  group: ClaudeMessageGroup,
  role: ClaudeContentBlock['role'],
  text: string,
): ClaudeContentBlock {
  const incompleteBlocks = group.blocksInOrder.filter(
    (block) => !block.completed && block.role === role,
  )
  const block =
    incompleteBlocks.find(
      (candidate) => candidate.accumulatedText === text,
    ) ??
    incompleteBlocks[0] ??
    createContentBlock(group, role)
  block.completed = true
  return block
}

如果 assistant envelope 只包含工具块,组仍然会被绑定到该原生消息,但不会凭空消耗一个文本块。后续不同内容的 envelope 可以继续使用同一原生 ID;assistantEnvelopeGroups 用“message ID + 完整 content”区分它们。

A.5缺少生命周期和重复投递走显式分支

真实 runner 同时处理普通 turn 和 slash control,而 Claude 的 stream-json 在这两类交互中都可能省略生命周期。runner 每次开始交互时重置 translation state,并明确允许 standalone delta;纯函数 translator 的默认值仍是关闭,避免测试或其他调用者误把孤立 delta 当成合法流。

apps/daemon/src/agent/providers/claude/runner.tsrunner 对真实协议缺省的选择
private resetTurnDiagnostics(): void {
  this.readerFailure = null
  this.stderrTail = ''
  this.translationState = createClaudeTranslationState({
    // Stream-json can omit lifecycle records for both turns and control commands.
    allowStandaloneDeltas: true,
  })
}

fallback group 一旦被带 ID 的 final envelope 接管,就从 fallbackGroup 槽位移走;下一条 lifecycle-free 消息会分配新组。反过来,只要本次交互已经见过生命周期,standalone 分支就永久关闭,孤立 delta 不能绕过已知协议。

apps/daemon/src/agent/providers/claude/translator.tsstandalone 入口
function standaloneDeltaGroup(
  state: ClaudeTranslationState,
): ClaudeMessageGroup | undefined {
  if (!state.allowStandaloneDeltas || state.streamLifecycleSeen) {
    return undefined
  }
  if (!state.fallbackGroup ||
      !fallbackGroupAcceptsDeltas(state.fallbackGroup)) {
    state.fallbackGroup = createMessageGroup(state)
  }
  return state.fallbackGroup
}
排查路标 · 旅程 A
症状从哪下手
delta 与 completion 的 itemId 不一致translator.ts:依次看 beginMessageStreamstreamBlockForDeltacompletionBlockForText
同一段完成消息出现两次translator.ts:看 replayingActiveStreamassistantEnvelopeGroups 的命中条件
控制命令有文本输出但没有 deltarunner.ts:确认 resetTurnDiagnostics 开启 standalone;再看 standaloneDeltaGroup
最终 envelope 没有 message ID 时另起了内容块translator.ts:看 messageGroupForIdlessAssistant 的 active/latest/fallback 顺序
持久化后两个相同文本块合并或丢失provider-transcript-fixture.test.ts:看两个 Echo part 的 m1b1/m1b2 断言

4心智模型补丁

assistant envelope 是消息身份的边界。 Claude 原生 message.id 才是分组边界;一个原生消息可以对应多个 final envelope。
itemId 可以从任意 payload 的数组下标重新计算。 itemId 在块首次分配时产生,后续事件必须从消息组取回它。
stream index 只在流阶段可信,final envelope 的本地下标不承担跨阶段身份。
最终文本与 streamed text 不同,意味着应该创建新块。 最终文本是权威校正;同角色块找不到精确文本时,按原始分配顺序完成已有块。
孤立 delta 总能直接翻译成消息事件。 纯 translator 默认拒绝孤立 delta;只有 runner 显式允许且本次交互从未出现生命周期时才走 fallback。
重复 provider 输出交给下游去重。 translator 记住已开始的 stream 和已处理的完整 assistant envelope,在事件生成前抑制重放。

5新词表

Claude 消息关联
message group一个 run 内属于同一 Claude 原生消息的文本、reasoning 块和 envelope 集合。
authoritative envelopeClaude 的顶层 assistant payload;它给出完成后的权威内容,可能拆成多份,也可能修正 streamed text。
fallback group生命周期缺失时临时承接 standalone delta 的消息组,原生 ID 到达后可被该消息接管。
replay同一 provider message 的 stream 或相同 assistant content 再次投递;translator 不再重复生成内容事件。
allocation order文本/reasoning 块首次创建的顺序;最终文本无法精确匹配时,用它决定认领哪个未完成块。

6测试与风险地图

有兜底的行为可见边界
生命周期门槛覆盖 delta 早于 start、index 未声明、message_stop 后继续 delta、畸形 message_start。
跨 envelope 关联覆盖 reasoning/text 分拆、同角色块换序、最终文本修正、最终 envelope 缺少 ID。
缺省与重放覆盖 lifecycle-free fallback 的接管/换组/单 ID 绑定,以及重复 stream 和重复 completion。
索引兼容覆盖数字 index 和可序列化为数字的字符串 index。
持久化结果provider transcript fixture 证明两个文本相同但身份不同的 Echo 块都能关闭并回放。
runner 行为session 测试补齐标准生命周期;既有 control、failure、interrupt、lifecycle、turn 测试继续用无生命周期 delta,间接覆盖 runner 的 standalone 选择。
事实性薄冰🟡新增关联场景由构造出来的 stream-json 行覆盖,仓库没有提交一份真实 Claude CLI 录制 transcript;本 PR 不改变 SessionSink 或 renderer,持久化后的语义通过 reducer fixture 验证,没有浏览器 E2E。

别被这些现象误导

7覆盖声明

本报告以 GitHub PR #147 的 main...fix/claude-native-message-id 为边界,全量阅读 6 个变更文件的每个 diff hunk:runner.tstranslator.ts、三个 Claude 测试/夹具文件和 provider transcript fixture。核心 translator.ts 同时完整阅读了 base 与 head 版本;报告中的每段代码都从这些一手文件裁剪。

925 行新增中,527 行集中在 translator 单测,另外 58 行位于 runner/session/helper/transcript 测试;没有 rename、锁文件、生成物或纯机械搬运可略读。PR body、7 条 commit subject 和 Claude provider 文档只用于确认动机与现有边界,不作为代码结论的替代证据。