PR #151:Provider 原生会话标题成为第一类数据

buffin-ai/buffin · main...feat/provider-native-session-titles · 2026-07-20 · 自包含,读完即弃

3 commits
58 文件
+1850 / −73
69.5% 是测试代码
功能型 PR

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

1TL;DR

这个 PR 让 Claude 和 Codex 生成的会话标题进入 Buffin,但不会覆盖用户自己起的名字。数据库同时保留用户覆盖值和 Provider 候选值,所有读接口只返回按优先级计算后的“有效标题”。

标题变化不进入持久化的 Agent 事件流,而是通过新的全局 sessions.deltas 临时订阅实时广播;断线后客户端靠查询失效和重取恢复一致性。

Desktop 的重命名输入现在可以清空:清空意味着移除用户覆盖,立即回落到 Provider 标题。PR 还处理了生成重试、重连竞态和编辑中的后台改名竞态。

2变更地图

daemon tests
911 行 · 47.4%
client/UI tests
425 行 · 22.1%
daemon runtime
348 行 · 18.1%
client dispatcher
90 行 · 4.7%
desktop runtime
81 行 · 4.2%
api + db
68 行 · 3.5%
区域设计重心(要细读)可放心略过
ProviderClaude 的原生生成请求、Codex 的 thread 元数据采集、失败不影响回合测试 fake server 的模式分支和 sink mock 补方法
Daemon双字段优先级、标题归一化、生命周期过滤、临时 delta feedrepository row、builder、wiring 的字段透传
Clientdetail cache 直补、list 失效、resync 与后续 delta 串行tRPC query key 形状的对齐断言
Desktop全局单订阅、清空覆盖值、pristine/dirty 编辑草稿既有订阅测试中预期路径多一个 sessions.deltas

按 numstat 统计共 1923 行改动,其中测试 1337 行。生产代码约 586 行,设计密度主要集中在两个 Provider runner、SessionService 和 dispatcher。

3架构一图流

以前 · 标题只有用户写入一条路

Desktop
sessions.update
title
Provider
无标题通路
UI

现在 · 两个来源,一个有效值

Provider
metadata sink
generated_title
SessionService
sessions.deltas
Client cache

关键边界是:标题属于 Session 元数据,不属于某次 run。因此 Provider 通过 AgentEventSink.publishGeneratedTitle 写入独立的 SessionService;这条路径不会追加 session.title.updated 一类持久事件。

4数据与状态先行

读行为之前先记住三个形状:数据库有两个标题槽位;API 只暴露有效标题;实时流只携带足够修补缓存的三项元数据。

packages/db/src/index.ts数据库字段与约束(节选)
title: text('title'),
generatedTitle: text('generated_title'),

titleLength: check(
  'agent_sessions_title_length',
  sql`${table.title} IS NULL OR length(${table.title}) <= 200`,
),
generatedTitleLength: check(
  'agent_sessions_generated_title_length',
  sql`${table.generatedTitle} IS NULL OR length(${table.generatedTitle}) <= 200`,
),
packages/api/src/dto.ts公开 DTO 与 delta
export type SessionDto = {
  id: string
  title: string | null // 有效值,不泄露两个底层槽位
  updatedAt: string
  // ...
}

export type SessionTitleDelta = {
  sessionId: string
  title: string | null
  updatedAt: string
}
状态谁维护作用
generatedTitleRecordedClaude runner已有候选后停止再次请求标题
titleGenerationAttemptsClaude runner跨回合最多尝试 3 次
titleRefreshInFlightCodex runner只防并发 read;失败后释放,下一回合可重试
dirtyinline edit hook区分未动过的草稿与用户已经输入的草稿

5旅程 A:Provider 产出标题

这条旅程从一次成功回合结束开始,到 Provider 候选值进入 Session 元数据 sink 为止。Claude 是“主动生成”,Codex 是“读取 Provider 已有的 thread 名称或预览”。

全景 · 两条 Provider 分支汇入同一 sink
run.completed
provider runner
生成 / 采集标题
Claude / Codex
publishGeneratedTitle SessionService
session-sink.ts

A.1Claude:终态先落地,标题随后 fail-open 生成

Claude 只在真实用户回合成功完成、尚无候选、没有请求在途、且重试预算未耗尽时发请求。它先把 run.completed 交给 sink,再异步请求标题,所以标题超时或写入失败不会改写已完成回合的终态。

apps/daemon/src/agent/providers/claude/runner.ts资格判断与请求启动(节选)
private titleGenerationEligible(): boolean {
  return (
    this.runContext?.kind === 'turn' &&
    !this.generatedTitleRecorded &&
    !this.titleRequestInFlight &&
    this.titleGenerationAttempts < TITLE_GENERATION_MAX_ATTEMPTS
  )
}

private startTitleGeneration(process: ClaudeProcess, description: string): void {
  this.titleRequestInFlight = true
  const attempt = ++this.titleGenerationAttempts
  const request = buildClaudeControlRequest({
    subtype: 'generate_session_title',
    description,
    persist: true,
  })
  void this.requestControl(process, request, TITLE_GENERATION_TIMEOUT_MS)
    .then(readGeneratedTitle)
    // ...成功写入;失败保持可重试
    .finally(() => { this.titleRequestInFlight = false })
}

请求描述来自当前用户输入,按 Unicode code point 截到 1000;单次等待 10 秒。空白、缺失、格式错误、超时和错误响应都不记为成功,下一次成功回合会重试,第三次后停止并记录日志。

A.2Codex:优先吃现成元数据,缺失时回读 thread

Codex 不额外让模型生成标题。它按 thread.name 优先、thread.preview 兜底,从 start、resume、name update 三类现成响应中采集;若第一次回合结束时仍没有候选,再发一次 thread/read

apps/daemon/src/agent/providers/codex/index.ts候选选择与回读(节选)
private async publishThreadTitle(thread: CodexThread): Promise<void> {
  const title = nonblank(thread.name) ?? nonblank(thread.preview)
  if (title) await this.publishTitleCandidate(title)
}

private async refreshThreadTitle(): Promise<void> {
  if (!this.threadId) return
  try {
    const response = await this.requirePeer().request<CodexThreadReadResponse>(
      'thread/read',
      { threadId: this.threadId, includeTurns: false },
      this.requestTimeoutMs,
    )
    if (!this.hasProviderTitleCandidate && response.thread.id === this.threadId) {
      await this.publishThreadTitle(response.thread)
    }
  } catch (err) {
    logger.warn({ err, threadId: this.threadId }, 'Codex session title refresh failed')
  }
}

titleRefreshInFlight 只是互斥锁,不是一次性门闩:finally 会释放它。第一次 thread/read 暂时失败时,下一次 completed 回合会再次尝试。来自别的 thread 的 name update 会被 thread id 校验挡掉。

排查路标 · 旅程 A
症状从哪下手
Claude 成功回合后一直没有标题claude/runner.tstitleGenerationEligibletitleGenerationAttempts 和控制请求日志
Codex 有 thread 名但 Buffin 没显示codex/index.tspublishThreadTitlecodex/protocol.tsthread/name/updated 解析
标题失败连带把回合标成失败session-sink.ts 与两个 runner 的 fail-open 调用位置;WebSocket 测试固定了终态不受影响

6旅程 B:持久化与实时同步

Provider 候选到达后,Daemon 先做归一化和可见性检查,再提交数据库并发布有效标题。客户端拿到 delta 后修 detail cache、刷新 list;若连接重建,则不猜丢了什么,直接让所有 Session 查询失效。

全景 · 从 metadata sink 到 Desktop cache
SessionSink
publishGeneratedTitle
SessionService
DB + precedence
sessions.deltas
ephemeral feed
dispatcher
TanStack cache

B.1写入只改候选,广播永远给有效值

Provider 字符串先 trim、把所有空白折叠成一个空格,再按 code point 截到 200。空值、相同值、归档、purge 中或已删除的 Session 都不写。成功提交后,delta 的 title 使用现有用户值优先。

apps/daemon/src/services/sessions.ts提交后广播(节选)
const result = this.db
  .update(agentSessionsTable)
  .set({ generatedTitle, updatedAt })
  .where(and(
    eq(agentSessionsTable.id, id),
    isNull(agentSessionsTable.archivedAt),
    notPurgedSessionWhere(),
  ))
  .run()

if (result.changes !== 1) return
this.deltas.publish(SESSION_DELTA_TOPIC, {
  sessionId: id,
  title: current.title ?? generatedTitle, // 用户覆盖仍然赢
  updatedAt: toIsoTimestamp(updatedAt),
})

普通的 sessions.update 也发布同一种 delta。用户设置标题时广播用户值;Provider 在用户覆盖存在时更新候选,广播的仍是用户值;用户清空覆盖时,广播最新 Provider 候选。

B.2临时流不回放,靠 resync 修复连续性

sessions.deltas 是全局 ephemeral feed:适合轻量元数据广播,但不承诺离线期间逐条回放。每次订阅先收到 resync marker,客户端把所有 Session list 和 detail 查询标为失效,让活跃观察者重新拉真相。

packages/client/src/dispatcher.ts正常 delta 与重连修复
export function applySessionTitleDelta(ctx, delta): Promise<void> {
  const detailKey = sessionGetQueryKey(delta.sessionId)
  if (ctx.queryClient.getQueryState(detailKey) !== undefined) {
    ctx.queryClient.setQueryData<SessionDto>(detailKey, (current) =>
      current ? { ...current, title: delta.title, updatedAt: delta.updatedAt } : current,
    )
  }
  return ctx.queryClient.invalidateQueries({ queryKey: sessionsListQueryKeyPrefix() })
}

export function resyncSessions(ctx): Promise<void> {
  return Promise.all([
    ctx.queryClient.invalidateQueries({ queryKey: sessionsListQueryKeyPrefix() }),
    ctx.queryClient.invalidateQueries({ queryKey: sessionGetQueryKeyPrefix() }),
  ]).then(() => undefined)
}

这里还有一个时间顺序保护:resync 引发的旧响应可能晚于新 delta 返回。dispatcher 用每个订阅自己的 Promise chain 严格按到达顺序执行 effect,让旧 refetch 先落,再应用新标题,避免新标题被旧响应覆盖。

排查路标 · 旅程 B
症状从哪下手
数据库有 generated_title,API 仍返回旧用户标题services/dto.tstoSessionDto;这是 title ?? generatedTitle 的预期优先级
详情页实时变了,但列表没变dispatcher.tsapplySessionTitleDeltasessionsListQueryKeyPrefix
重连瞬间标题回退dispatcher.tspendingSessionEffect 串行链;runtime test 覆盖了 stale refetch 交错
归档 Session 仍在改标题services/sessions.ts 的 archived/purge where 条件

7旅程 C:用户重命名与回落

用户看到的一直是有效标题。点击铅笔时,输入框同步以当前值种下草稿;输入空白并提交,会发送 title: null,表示删除覆盖,而不是把空字符串存入数据库。

全景 · 一次清空操作
NavRow
beginEdit
空白 blur
onClear
sessions.update title:null 有效标题回落
generatedTitle
以前
输入框靠 effect 稍后 seed,首帧可能为空
空值只回退草稿,不表达“删除覆盖”
现在
beginEdit() 在 input 挂载前同步 seed
空值走 onClear,服务端恢复 Provider 候选
apps/desktop/src/renderer/lib/use-inline-edit-field.tspristine / dirty 协调(节选)
function onChange(next: string) {
  dirty.current = true
  setValue(next)
}

function beginEdit() {
  dirty.current = false
  setValue(serverValue)
}

function onBlur() {
  const next = value.trim()
  if (next.length === 0) {
    if (onClear) onClear()
    else setValue(serverValue)
    return
  }
  if (next === serverValue) {
    dirty.current = false
    setValue(serverValue)
    return
  }
  onCommit(next)
}

编辑期间若 Provider 推来新标题:用户还没输入时,草稿跟随新 serverValue,blur 不会把旧标题写回;用户已经输入时,dirty 草稿保持不动,最终提交用户值。这就是第三个 commit 专门修的前后台竞态。

排查路标 · 旅程 C
症状从哪下手
点击重命名时输入框闪一下空白NavRow.tsx 点击处理中的 field.beginEdit()
清空后没有回落到 Provider 标题NavRow.tsxonClear,再看 SessionService.update 返回的有效标题
编辑中 Provider 改名覆盖了用户输入use-inline-edit-field.tsdirty 与 same-identity effect 分支

8心智模型补丁

Session 的 title 就是数据库里的用户字段。 公开 SessionDto.title 是计算值;数据库中用户覆盖与 Provider 候选分开。
排查显示问题时,必须同时看 titlegenerated_title 和优先级映射。
所有 Agent 相关变化都应该能在 run event log 中回放。 标题是独立 Session 元数据,实时通知走 ephemeral feed,不污染 run log。
订阅只需要围绕打开的 tab 建立。 Desktop 启动后始终保留恰好一个全局 sessions.deltas 订阅,与打开多少 Session 无关。
空白重命名是无效输入,恢复原值即可。 对 Session 来说,空白重命名有领域含义:删除用户覆盖,回落到 Provider 候选。
同一资源的后台 refetch 不应该碰正在编辑的草稿。 只有 dirty 草稿需要保护;pristine 草稿必须跟随后台标题,避免 blur 写回陈旧值。

9新词表

Session 标题域
user override用户明确设置的标题,存于原有 title 字段,优先级最高。
provider candidateProvider 生成或携带的候选标题,存于 generated_title,不会覆盖用户值。
effective title对外显示值:title ?? generatedTitle
同步域
sessions.deltas全局 Session 标题元数据临时流;正常时推 delta,订阅/重连时先发 resync。
resync marker“不能证明你没漏数据”的信号;客户端收到后让查询重新拉取真相。
pristine draft用户尚未输入的编辑草稿,会继续跟随服务端标题变化。

10测试与风险地图

有兜底的事实边界 / 薄冰
Claude:首次成功、错误/空/畸形/超时重试、3 次上限、单请求在途、进程退出恢复、已有标题不再请求自动标题生产者目前明确接在 Claude 和 Codex;ACP sink 实现仍是 no-op。
Codex:start name、preview fallback、resume name、thread/read、晚到 name update、错误 thread 过滤、read 失败下回合重试客户端拿不到原始 generated_title;API 有意只暴露有效标题。
Daemon:优先级、200 字归一化、filter 使用有效标题、归档/purge/delete 忽略写入、双 WebSocket fan-out、run log 不受污染🟡sessions.deltas 不回放离线 delta;正确性依赖 resync 后查询 refetch,这一行为已有 runtime 测试。
Client/UI:detail patch、list invalidate、重连全失效、resync/delta 串行、全局单订阅、首帧 seed、清空回落、pristine/dirty 竞态PR 描述提到 WSL2 下 terminal-ws.test.ts 的既有时序失败;当前 GitHub verify 检查已通过。
当前验收状态:GitHub 上 Commit messages、Title & description、verify 三项检查均通过。这个 PR 没有声明需要额外迁移操作;它直接更新当前的 0000_init baseline 与 snapshot。
验收时别误判:Provider 更新候选标题时,即使用户覆盖仍在,delta 也会广播,但携带的 title 仍是用户值;这是为了让所有消费者只处理“有效标题”,不是覆盖失败。标题事件在 durable run log 中缺席同样是刻意边界。

覆盖声明:本报告基于 PR 的 3 个 commit、58 个文件和完整 diff。逐文件读取了数据库/API 形状、Daemon wiring、Claude/Codex runner、SessionService、tRPC 路由、client dispatcher、Desktop 编辑逻辑及新增/改动测试;一行式 mock/fixture 适配按接口变更归类,没有把它们当作独立设计。