PR #151:Provider 原生会话标题成为第一类数据
buffin-ai/buffin · main...feat/provider-native-session-titles · 2026-07-20 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 让 Claude 和 Codex 生成的会话标题进入 Buffin,但不会覆盖用户自己起的名字。数据库同时保留用户覆盖值和 Provider 候选值,所有读接口只返回按优先级计算后的“有效标题”。
标题变化不进入持久化的 Agent 事件流,而是通过新的全局 sessions.deltas 临时订阅实时广播;断线后客户端靠查询失效和重取恢复一致性。
Desktop 的重命名输入现在可以清空:清空意味着移除用户覆盖,立即回落到 Provider 标题。PR 还处理了生成重试、重连竞态和编辑中的后台改名竞态。
2变更地图
| 区域 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| Provider | Claude 的原生生成请求、Codex 的 thread 元数据采集、失败不影响回合 | 测试 fake server 的模式分支和 sink mock 补方法 |
| Daemon | 双字段优先级、标题归一化、生命周期过滤、临时 delta feed | repository row、builder、wiring 的字段透传 |
| Client | detail cache 直补、list 失效、resync 与后续 delta 串行 | tRPC query key 形状的对齐断言 |
| Desktop | 全局单订阅、清空覆盖值、pristine/dirty 编辑草稿 | 既有订阅测试中预期路径多一个 sessions.deltas |
按 numstat 统计共 1923 行改动,其中测试 1337 行。生产代码约 586 行,设计密度主要集中在两个 Provider runner、SessionService 和 dispatcher。
3架构一图流
以前 · 标题只有用户写入一条路
现在 · 两个来源,一个有效值
关键边界是:标题属于 Session 元数据,不属于某次 run。因此 Provider 通过 AgentEventSink.publishGeneratedTitle 写入独立的 SessionService;这条路径不会追加 session.title.updated 一类持久事件。
4数据与状态先行
读行为之前先记住三个形状:数据库有两个标题槽位;API 只暴露有效标题;实时流只携带足够修补缓存的三项元数据。
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`,
),
export type SessionDto = {
id: string
title: string | null // 有效值,不泄露两个底层槽位
updatedAt: string
// ...
}
export type SessionTitleDelta = {
sessionId: string
title: string | null
updatedAt: string
}
| 状态 | 谁维护 | 作用 |
|---|---|---|
generatedTitleRecorded | Claude runner | 已有候选后停止再次请求标题 |
titleGenerationAttempts | Claude runner | 跨回合最多尝试 3 次 |
titleRefreshInFlight | Codex runner | 只防并发 read;失败后释放,下一回合可重试 |
dirty | inline edit hook | 区分未动过的草稿与用户已经输入的草稿 |
5旅程 A:Provider 产出标题
这条旅程从一次成功回合结束开始,到 Provider 候选值进入 Session 元数据 sink 为止。Claude 是“主动生成”,Codex 是“读取 Provider 已有的 thread 名称或预览”。
provider runner→ 生成 / 采集标题
Claude / Codex→ publishGeneratedTitle→ SessionService
session-sink.ts
A.1Claude:终态先落地,标题随后 fail-open 生成
Claude 只在真实用户回合成功完成、尚无候选、没有请求在途、且重试预算未耗尽时发请求。它先把 run.completed 交给 sink,再异步请求标题,所以标题超时或写入失败不会改写已完成回合的终态。
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。
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.ts:titleGenerationEligible、titleGenerationAttempts 和控制请求日志 |
| Codex 有 thread 名但 Buffin 没显示 | codex/index.ts:publishThreadTitle;codex/protocol.ts:thread/name/updated 解析 |
| 标题失败连带把回合标成失败 | session-sink.ts 与两个 runner 的 fail-open 调用位置;WebSocket 测试固定了终态不受影响 |
6旅程 B:持久化与实时同步
Provider 候选到达后,Daemon 先做归一化和可见性检查,再提交数据库并发布有效标题。客户端拿到 delta 后修 detail cache、刷新 list;若连接重建,则不猜丢了什么,直接让所有 Session 查询失效。
publishGeneratedTitle→ SessionService
DB + precedence→ sessions.deltas
ephemeral feed→ dispatcher
TanStack cache
B.1写入只改候选,广播永远给有效值
Provider 字符串先 trim、把所有空白折叠成一个空格,再按 code point 截到 200。空值、相同值、归档、purge 中或已删除的 Session 都不写。成功提交后,delta 的 title 使用现有用户值优先。
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 查询标为失效,让活跃观察者重新拉真相。
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.ts 的 toSessionDto;这是 title ?? generatedTitle 的预期优先级 |
| 详情页实时变了,但列表没变 | dispatcher.ts 的 applySessionTitleDelta 与 sessionsListQueryKeyPrefix |
| 重连瞬间标题回退 | dispatcher.ts 的 pendingSessionEffect 串行链;runtime test 覆盖了 stale refetch 交错 |
| 归档 Session 仍在改标题 | services/sessions.ts 的 archived/purge where 条件 |
7旅程 C:用户重命名与回落
用户看到的一直是有效标题。点击铅笔时,输入框同步以当前值种下草稿;输入空白并提交,会发送 title: null,表示删除覆盖,而不是把空字符串存入数据库。
beginEdit→ 空白 blur
onClear→ sessions.update title:null→ 有效标题回落
generatedTitle
beginEdit() 在 input 挂载前同步 seedonClear,服务端恢复 Provider 候选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.tsx 的 onClear,再看 SessionService.update 返回的有效标题 |
| 编辑中 Provider 改名覆盖了用户输入 | use-inline-edit-field.ts 的 dirty 与 same-identity effect 分支 |
8心智模型补丁
title 就是数据库里的用户字段。
公开 SessionDto.title 是计算值;数据库中用户覆盖与 Provider 候选分开。
title、generated_title 和优先级映射。sessions.deltas 订阅,与打开多少 Session 无关。
9新词表
| Session 标题域 | |
|---|---|
user override | 用户明确设置的标题,存于原有 title 字段,优先级最高。 |
provider candidate | Provider 生成或携带的候选标题,存于 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 检查已通过。 |
0000_init baseline 与 snapshot。
覆盖声明:本报告基于 PR 的 3 个 commit、58 个文件和完整 diff。逐文件读取了数据库/API 形状、Daemon wiring、Claude/Codex runner、SessionService、tRPC 路由、client dispatcher、Desktop 编辑逻辑及新增/改动测试;一行式 mock/fixture 适配按接口变更归类,没有把它们当作独立设计。