PR #75 user-message-event:让事件流补上「用户自己这一轮」

figuretu/eyrie · main...feat/user-message-event @ 2af4015 · 2026-06-16 · 自包含,读完即弃

1 commit
12 文件
+284 / −19
~73% 是测试代码
类型 · 功能(加一条新事件)

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

1TL;DR

agent.events 是客户端重建一个会话时间线的唯一真相源——断线重连后,前端靠重放这串事件把对话画回来。但在此 PR 之前,这串事件里从来没有「用户说了什么」这一条:用户输入只被写进 runs.inputText 这一列(落库、但不进事件流),于是前端时间线里第一条业务事件是 run.started,用户自己的消息气泡无从渲染——这正是前端报上来的 Blocker。

本 PR 加一种事件类型 message.userstartTurn 在每一轮里把用户这轮输入作为一条事件持久化 + 广播,排在 run.started 之前,并且即使这一轮启动失败也保留。非测试改动只有 82 行(1 个事件类型 + 1 个 encoder + 1 处 emit + 同步 DB 的 CHECK 约束);其余 221 行全是把这些行为钉死的测试。不动传输层、不动 client SDK、不出独立版本迁移脚本。

2形状先行

下潜前先认两个新形状,旅程里会反复用到。

第一,事件联合里多了一个成员。注意它身上没有 role 字段——role 是落库时由 encoder 钉成 'user' 的(见 A.3),联合成员本身只携带 text 和可选的 parts

apps/daemon/src/agent/types.tsAgentProviderEvent 新成员
export type AgentProviderEvent = AgentProviderEventBase &
  (
    // ...assistant 流式增量、工具、审批等既有成员...
    | { type: 'message.delta'; role: 'assistant' | 'reasoning'; text: string; itemId?: string }
    | { type: 'message.user'; text: string; parts?: AgentInputPart[] }   // 新增:用户这一轮
    // ...
  )

第二,一个被提升为导出的类型守卫 allTextParts。它回答一个很窄的问题:「这串 parts 是不是全部都是纯文本块?」——这是本期唯一安全落库的 parts 形状(为什么这么窄见 A.2)。它被 service 和 codec 两处共用,所以从局部拷贝提成了共享导出:

apps/daemon/src/agent/types.ts共享类型守卫
export type AgentInputPart =
  | { type: 'text'; text: string }
  | { type: 'image'; path: string; mimeType: string }   // 注意 image 块带宿主 path

// 把一串 parts 收窄成「只含 text 块」,这是今天唯一安全持久化到用户事件上的形状。
export function allTextParts(
  parts: AgentInputPart[] | undefined,
): parts is Array<{ type: 'text'; text: string }> {
  return parts !== undefined && parts.length > 0 && parts.every((part) => part.type === 'text')
}

3旅程:一次 startTurn 如何把用户输入写进事件流

这是本 PR 唯一的旅程,也是它的全部价值所在。跟着一次 startTurn('session-1', { text: 'hello' }) 走一遍:从落点、到安全闸、到编码进库、到断线重放回原样。走通后你会知道用户气泡是从哪一行冒出来的、顺序由谁保证、图片为什么不进流。

全景 · 涉及 4 个文件
落点
service.ts · startTurn
安全闸
service.ts + types.ts
持久化 + 广播
session-sink.ts · emit
编码
event-codec.ts · encodeMessageUser
CHECK 闸
packages/db
重放
event-codec.ts · decodeEventRow

A.1落点:为什么 emit 必须在 runner 之前

先摆清两件要同时满足的事:① message.user 的序号必须小于本轮任何 provider 事件,否则前端时间线里用户气泡会排在助手回复后面;② 这一轮哪怕启动失败,用户消息也得留痕——失败的 run 也该有一条诚实的时间线,告诉你「用户确实问了,但没跑起来」。

这两条共同把 emit 逼到了一个很具体的位置:在 beginTurn 拿到 run 之后、在 getOrCreate 构建 runner 之前,且整段仍在原有 try 内(emit 或构建任一抛错都走 terminateRun)。sink 的取法是「有活的就复用,没有就临时造一个 service-owned 的」——因为 runner 还没建,可能根本没有 live sink:

apps/daemon/src/agent/service.ts · startTurn真实代码(节选)
const run = await this.repo.beginTurn(sessionId, {
  id: createId(), inputText: normalized.text, startedAt: Date.now(),
})

try {
  const liveHandle = this.runnerManager.get(sessionId)
  const userEventSink =
    liveHandle?.sink ??
    new SessionSink(session.id, session.providerId, this.repo, this.broadcaster)  // 没活 runner 就临时造一个 sink
  userEventSink.setCurrentRun(run.id)
  await userEventSink.emit(buildUserMessageEvent(normalized))   // ← 必须 await 完,才保证 seq 在 run.started 之前

  const handle = await this.runnerManager.getOrCreate(/* ... */)  // 构建 runner(可能抛错)
  handle.sink.setCurrentRun(run.id)
  await handle.runner.startTurn(normalized)
} catch (err) {
  await this.repo.terminateRun(run.id, 'startTurnFailed')   // 此时 message.user 已落库,run 标 failed
  throw err
}

这处落点和最初计划不同(计划是放在 getOrCreate 之后handle.sink)。改的原因是个真实的丢消息缺口,见第 4 节偏差 ②。把两版并排看:

base(本 PR 之前)
beginTurn:把 inputText 存进 run 行
getOrCreate 构建 runner
runner.startTurn —— 无任何用户事件,流里第一条是 run.started
现在
beginTurn:把 inputText 存进 run 行
emit message.user(live-or-service sink,await 完)
getOrCreate → runner.startTurn —— message.user 已在流里且排在 run.started 之前

构造事件的 helper 很短,但它替整条旅程做了关键裁剪——parts 只在「全 text」时才挂上去:

apps/daemon/src/agent/service.tsbuildUserMessageEvent
function buildUserMessageEvent(
  input: AgentInput,
): Extract<AgentProviderEvent, { type: 'message.user' }> {
  const parts = allTextParts(input.parts) ? input.parts : undefined   // 非全 text 一律置 undefined
  return {
    type: AgentEventType.MessageUser,
    text: input.text,
    ...(parts ? { parts } : {}),   // 没有就连 key 都不放,而不是放个空数组
  }
}

A.2安全闸:为什么图片输入不进 parts

这是本 PR 最容易被忽略、却最有设计判断的一处。用户输入可能是图文混合(AgentInputPartimage 块带一个 path——那是宿主机上的本地文件路径)。如果原样把 parts 落进持久事件,等于把宿主 path 焊进了一份会被断线重放、会被前端消费的契约里。

本期的处理是:text 永远在(图文混合时由 normalizeInput[image: image/png] 这样的占位拼出诚实回显),但 parts 只在全部都是 text 块时才落库;只要掺了一个 image/ref 块,整组 parts 省略——而不是给消费者一个「缺了图片的半截 parts」。text 在这种情况下就是权威回显。

apps/daemon/src/agent/service.ts · normalizeInputtext 怎么从 parts 派生
const text = input.parts
  .map((part) => (part.type === 'text' ? part.text : `[image: ${part.mimeType}]`))  // 图片只露 mime,不露 path
  .join('\n')
return { text, parts: input.parts }
为什么不是直觉做法:直觉会原样存 parts,让前端自己渲染图片。但 image 块的「opaque upload-ref」契约还没定(留到 Slice 2 和上传闭环一起做)。在那之前,宁可只存权威 text、不存半截 parts,也不把宿主 path 写死进事件——一旦写进去,就成了断线重放也会复现的历史数据,回收成本远高于现在收紧。

A.3编码进库:role、不脱敏、与 input.requested 的反向

事件落库走 SessionSink.emit:它先 encodeEvent 把联合成员翻译成行字段,再 appendEvent 在一个串行化事务里分配单调的 seq/sessionSeq 并插入,最后只广播已提交的那一行。这条链路是既有的(本 PR 没碰 sink),新增的只是 message.user 的 encoder。

encoder 干三件事,每件都有判断:role 钉成 'user'(落到索引列,replay 时再读回);parts 这里再过一次 allTextParts(双保险,即便有人绕过 helper 直接 emit);rawJsonnull——这点和 input.requested 正好相反:

apps/daemon/src/agent/event-codec.tsencodeMessageUser
function encodeMessageUser(providerId, event, _context, _rawJson): EncodedEvent {
  return eventResult(providerId, {
    role: 'user',
    payload: { text: event.text, parts: allTextParts(event.parts) ? event.parts : undefined },
    // 与 input.requested 脱敏其 raw body 不同:用户这一轮不含 provider 敏感数据,
    // 所以 text 原样持久化作为时间线内容。image ref 在 upload-ref 契约 opaque 之前一律不带——
    // 混合 parts 否则会泄漏宿主路径或半截块,所以 parts 仅在每块都是 text 时才持久化。
    rawJson: null,
  })
}

这个 encoder 必须被登记进派发表 encodeByType。这张表带 satisfies Record<AgentEventType, EventEncoder>——也就是说,加了新事件类型却忘了登记 encoder,编译期就会红,不会留到运行时:

apps/daemon/src/agent/event-codec.ts派发表(编译期穷尽)
const encodeByType = {
  // ...
  [AgentEventType.MessageCompleted]: encodeMessageCompleted,
  [AgentEventType.MessageUser]: encodeMessageUser,   // 漏了这行,satisfies 会让 tsc 报缺键
  [AgentEventType.ToolStarted]: encodeToolStarted,
  // ...
}

A.4重放:DB 这道 CHECK 闸,与解码回事件

事件行真正落库前还要过一道 SQLite CHECK 约束——agent_events 表对 typerole 两列都枚举了允许值。这意味着新增 message.user / role='user' 不只是 TS 层的事,DB schema 不放行就会在 INSERT 时直接撞约束。所以本 PR 在三个层同步加值:Drizzle 表定义、baseline SQL、以及 Drizzle 的快照 JSON:

packages/db/src/index.ts  +  migrations/0000_init.sql  +  meta/0000_snapshot.json三层同步的 CHECK
// type 白名单加一个成员:
'message.completed',
'message.user',          // ← type CHECK 三处都加
'tool.started',

// role 白名单从 (assistant, reasoning) 扩到含 user:
roleValid: check(
  'agent_events_role_valid',
  sql`${table.role} IS NULL OR ${table.role} IN ('assistant', 'reasoning', 'user')`,  // ← 多了 'user'
),

重放侧不需要为新类型写任何特判——这是既有 codec 设计的红利。decodeEventRow 是通用的:role 从行的索引列读回(readIndexedPayloadFields),text/parts 从 payload JSON 通用展开,再拼出事件联合。于是断线后重放出来的就是 { type:'message.user', role:'user', text, parts? },和落库前一致。

apps/daemon/src/agent/event-codec.ts · decodeEventRow通用解码,无新类型分支
event: {
  runId: row.runId, sessionId: row.sessionId, providerId: row.providerId,
  type: row.type,
  ...readIndexedPayloadFields(row),    // role 从索引列读回,message.user 在此自动拿回 'user'
  ...omitRowOwnedPayloadFields(payload),  // text / parts 从 payload 通用展开
  raw,
} as AgentEvent
排查路标 · 用户消息进事件流
症状从哪下手
前端时间线里没有用户气泡service.ts startTurn 的 emit 落点是否执行;DB 里 agent_events 有无 type='message.user'
message.user 排在了助手回复后面(顺序错)service.ts:emit 必须 await 完再 getOrCreate/startTurnseqsession-sink.tsemit 在事务内分配
startTurnCHECK constraint failedpackages/dbagent_events 的 type/role CHECK 是否同步了新值;旧库需重建(见偏差 ① 与第 8 节)
图片输入把宿主 path 泄进了事件 / parts 缺块event-codec.ts encodeMessageUser + types.ts allTextParts:parts 只在全 text 时落库
runner 构建失败后时间线没有用户消息service.ts:emit 在 getOrCreate 之前、用 live-or-service sink(偏差 ②)

4计划 vs 实现的偏差

照 spec/plan 直接做成的部分(加事件类型、加 encoder、加 emit)不在这里——那些读者从计划就能预期。这一节只收实质偏差:计划写错或漏判、实现期被迫改道的地方,这些才是认知裂缝。

偏差计划怎么说实际做成什么 / 为什么变
① DB 不是「无迁移」 spec 和首轮 review 都写「无 DB migration」——以为只是 TS 层加枚举值 实现期发现 agent_eventstype/roleCHECK 约束枚举,不放行就 INSERT 撞约束。改为在 Drizzle 表定义 + baseline SQL + 快照 JSON 三层同步加值。这是一次纸面 review 漏判被实现纠正——校正后 spec/plan/索引措辞都改成「同步 dev baseline CHECK 约束」。
② emit 落点前移 计划:在 getOrCreate 之后、走 handle.sink emit Codex 二轮复核发现真实丢消息缺口:runner 构建失败时还没有 handle.sink,那一支用户消息会丢。前移到 getOrCreate 之前、用 live-or-service sink,使「构建失败」和「startTurn 失败」两支都保留 message.user。对应新增了一条「keeps the user message when runner creation fails」测试。
③ parts 收紧 早期想法:把 parts 原样落库 Codex 代修:含 image/ref 时整组省略 parts、只留权威 text,避免把宿主 path 焊进持久事件、也避免半截 parts 误导消费者。image/ref 形状留到 Slice 2 与 upload-ref 契约一起定。allTextParts 因此从局部拷贝提成共享导出。
④ 注释去 provider-only 计划仅加类型 + encoder Codex §4a 指出 AgentProviderEvent / AgentEventSink.emit 的 doc 还写着「provider runners 发出」,但 message.userservice 发的。顺手把注释改成「provider runners 或 daemon service」,并点名 service-originated 成员(message.user / approval.resolved / input.resolved)。是否拆「service 事件 vs provider 事件」双通道判为本期成本不匹配,仅改注释。

5心智模型补丁

agent.events 只装 provider(助手/工具/审批)发出的东西,用户输入只在 runs.inputText 列里 事件流也装用户这一轮message.user),它由 service 而非 provider 发出,是时间线的第一条业务事件
推论:读 AgentProviderEvent 时别再默认「全是 provider 发的」——service-originated 成员现在有 message.user / approval.resolved / input.resolved 三个,注释已点名。
事件的 role 只可能是 assistant / reasoning(或 null) role 多了 'user';这个白名单由 agent_events 的 CHECK 约束在 DB 层兜底
所以「加一种带新 role 的事件」永远是 TS + DB 双层改动,不是纯 TS。
用户输入的 parts(含图片)原样就能落库/重放 只有全 text的 parts 才进事件;含 image/ref 时整组省略,靠 text(带 [image: mime] 占位)回显
宿主 path 不进持久契约;图片的正式形状等 upload-ref 闭环(Slice 2)。
在 daemon 里加一种事件类型,可能漏注册 encoder 到运行时才发现 encodeByTypesatisfies Record<AgentEventType, EventEncoder> 让漏注册在 编译期就红

6新词表

本 PR 新增 / 提升的词
message.user新事件类型,代表「用户这一轮的输入」,每次 startTurn 发一条,排在 run.started 之前。
allTextParts类型守卫:判断一串输入 parts 是不是全部为纯文本块——本期唯一安全落库的 parts 形状。由局部拷贝提升为 types.ts 的共享导出。
service-originated 事件由 daemon service(而非 provider runner)发出的事件。现有三个:message.userapproval.resolvedinput.resolved
live-or-service sinkstartTurn 取 sink 的策略:有活的 runner 就复用它的 sink,没有就临时 new SessionSink(...)——保证 runner 还没建好时也能 emit。

7测试与风险地图

纯事实陈述。本 PR 测试占比 ~73%,行为钉得相当密。

有兜底的(测试钉住的行为)

薄冰(无测试 / 已知遗留)

合并前/部署注意(🟠 不是代码缺陷,是机制约束):本 PR 原地改 0000_init baseline 而非出新迁移。已应用过 0000_init 的旧库不会通过 migrate() 收到放宽后的 CHECK,startTurnmessage.user 会撞旧约束。这是 dev 阶段单基线约定的已知取舍——schema 变更即删本地 dev 库重建,dogfood 前不做保数据迁移;当前没有要保的库。Codex bot 在 PR 上以 P1 标了这一条,按约定属 false-positive-by-context,不改。

8验收提示 + 覆盖声明

别被这些吓到

覆盖声明

本报告由主理 agent 全量精读:12 个改动文件的 diff 全部过目,并亲自 Read 了 service.ts(startTurn + normalizeInput + buildUserMessageEvent)、session-sink.ts(emit 全文)、event-codec.ts(encodeEvent / decodeEventRow / eventResult / 索引字段读写 / encodeMessageUser)、types.ts(联合 + allTextParts)原文裁剪代码,未经 subagent 转述。DB 三层改动经 diff 核对一致。无抽样、无略读。报告为一次性理解辅助,不维护、不作真相源。