跳转到内容

用户输入与会话

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读40 分钟
  • 章节 ID:02-session-message
  • 章节摘要:从 text + file prompt 出发,理解 HTTP payload 如何选择 agent/model、解析 typed parts、经插件后持久化,并在 noReply 与 agent loop 之间分流。
  • 教程版本:eec0843c
  • 源码基线:eec0843ce42298080569ca31a6455bc3f699d213
  • 章节元数据:/versions/eec0843c/data/chapters.json
  • 源码映射:/versions/eec0843c/data/source-map.json
  • packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/session.ts
  • packages/opencode/src/session/message-v2.ts

本章以 OpenCode 源码版本 eec0843ce422 为证据基线。我们沿用上一章的典型输入:CLI 向一个 session 发送“解释这个项目”,并附带 package.json。这里追踪的是源码允许的一条典型路径,不是一次真实会话录屏。

学完这一章,你应该能够:

  1. 画出 HTTP payload -> SessionPrompt.prompt -> user message + parts -> agent loop 的位置图。
  2. 区分 session、message、part 与 prompt input 四种数据。
  3. 解释 agent/model 为什么被记录在 user message,而不是只存在请求参数中。
  4. 沿源码追踪 text part 与本地 file part 怎样被解析、扩展并持久化。
  5. 说明 noReply、插件修改、文件读取失败和实验事件双写的边界。
  6. 为自己的 mini agent 设计一份可恢复的会话账本。

用户输入与会话层把一次短暂的外部请求,转换成 session 中可持久化、可重放的 user message 与 typed parts,再把这份稳定事实交给 agent loop。

本章的中心问题是:

CLI 发来的文本和附件,为什么不能原样塞进一个字符串就交给模型?

因为后续循环需要知道这条输入属于哪个 session、选择了哪个 agent/model、哪些内容来自用户、哪些是文件解析产生的 synthetic context,还要支持恢复、插件、事件与多种附件。证据入口见 packages/opencode/src/session/prompt.ts:689-731packages/opencode/src/session/prompt.ts:1065-1117

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:689-731
689    const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690      const agentName = input.agent691      const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692      if (!ag) {按条件进入分支。693        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697        throw error失败时抛出错误。698      }699700      const current = Database.use((db) =>701        db702          .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703          .from(SessionTable)704          .where(eq(SessionTable.id, input.sessionID))705          .get(),706      )707      const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708      const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709      const full =710        !input.variant && ag.variant && same711          ? yield* provider选择模型或 provider。712              .getModel(model.providerID, model.modelID)选择模型或 provider。713              .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714          : undefined715      const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)716717      const info: MessageV2.User = {会话消息片段结构。718        id: input.messageID ?? MessageID.ascending(),719        role: "user",720        sessionID: input.sessionID,721        time: { created: Date.now() },722        tools: input.tools,723        agent: ag.name,724        model: {选择模型或 provider。725          providerID: model.providerID,选择模型或 provider。726          modelID: model.modelID,选择模型或 provider。727          variant,728        },729        system: input.system,730        format: input.format,731      }
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1065-1117
1065      const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066        Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067      )10681069      yield* plugin.trigger(调用插件扩展点。1070        "chat.message",1071        {1072          sessionID: input.sessionID,1073          agent: input.agent,1074          model: input.model,选择模型或 provider。1075          messageID: input.messageID,1076          variant: input.variant,1077        },1078        { message: info, parts: resolvedParts },1079      )10801081      const parts = yield* Effect.forEach(resolvedParts, (part) =>Effect 异步工作流。1082        part.type === "file" && part.mime.startsWith("image/")1083          ? image.normalize(part).pipe(1084              Effect.catchIf(Effect 异步工作流。1085                (error) => error instanceof Image.ResizerUnavailableError,1086                () => Effect.succeed(part),Effect 异步工作流。1087              ),1088            )1089          : Effect.succeed(part),Effect 异步工作流。1090      )10911092      const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })1093      if (Exit.isFailure(parsed)) {按条件进入分支。1094        log.error("invalid user message before save", {1095          sessionID: input.sessionID,1096          messageID: info.id,1097          agent: info.agent,1098          model: info.model,选择模型或 provider。1099          cause: Cause.pretty(parsed.cause),1100        })1101      }1102      parts.forEach((part, index) => {1103        const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104        if (Exit.isSuccess(p)) return按条件进入分支。1105        log.error("invalid user part before save", {1106          sessionID: input.sessionID,1107          messageID: info.id,1108          partID: part.id,1109          partType: part.type,1110          index,1111          cause: Cause.pretty(p.cause),1112          part,1113        })1114      })11151116      yield* sessions.updateMessage(info)等待 Effect 结果。1117      for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。

2. 为什么会话层是 Agent 的“账本入口”

Section titled “2. 为什么会话层是 Agent 的“账本入口””

一次 HTTP 请求会结束,一次 CLI 进程也会退出;coding agent 的任务却可能跨越多轮模型请求、多个工具调用,甚至被中断后继续。

如果只把最后一句字符串留在内存中,会立刻遇到这些问题:

  • 工具结果属于哪次模型回应?
  • 恢复 session 时使用哪个 agent 和 model?
  • 文件、图片、MCP resource 与普通文本怎样区分?
  • UI 如何看到 part 的增量变化?
  • 插件应在何处修改输入,才不会绕过持久化?

所以 OpenCode 先“记账”,再“干活”。这一顺序由 SessionPrompt.prompt 的控制流直接证明:createUserMessagesessions.touch 发生在 loop(...) 之前。来源:packages/opencode/src/session/prompt.ts:1211-1229

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1211-1229
1211    const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212      "SessionPrompt.prompt",把输入交给会话主流程。1213    )(function* (input: PromptInput) {1214      const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215      yield* revert.cleanup(session)等待 Effect 结果。1216      const message = yield* createUserMessage(input)等待 Effect 结果。1217      yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219      const permissions: Permission.Ruleset = []1220      for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221        permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222      }1223      if (permissions.length > 0) {按条件进入分支。1224        session.permission = permissions1225        yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226      }12271228      if (input.noReply === true) return message按条件进入分支。1229      return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。

3. 先画地图:四层数据不要混在一起

Section titled “3. 先画地图:四层数据不要混在一起”
CLI / SDK / 其他客户端
|
v
PromptPayload / PromptInput 一次调用的输入草稿
|
v
SessionPrompt.createUserMessage 选择 agent/model,解析 parts
|
+------> MessageV2.User 这一轮用户消息的元信息
|
+------> MessageV2.Part[] 文本、文件、agent、subtask...
|
v
Session.Service 持久化并发布更新
|
v
SessionPrompt.loop 消费整段历史
概念最短定义生命周期
session一个可持续推进的任务档案跨多条消息、可继续或 fork
messageuser 或 assistant 的一轮记录属于一个 session
partmessage 内的一块 typed content/state可独立更新
prompt input外部调用提交的草稿只存在于入口转换阶段

Java 可以把 session 暂时类比为 aggregate root,message/part 类似子实体。但不要把它直接等同于 JPA 聚合:OpenCode 的 Schema 还承担运行时校验,message parts 又与流事件更新紧密相连。

4. 最小机制:先看从草稿到账本的骨架

Section titled “4. 最小机制:先看从草稿到账本的骨架”
1async function prompt(input: PromptInput) {定义一段可复用逻辑。2  const session = await sessions.get(input.sessionID)3  const user = chooseAgentAndModel(input, session)4  const parts = await Promise.all(input.parts.map(resolvePart))并行等待多个任务。56  await plugins.beforeSave({ message: user, parts })7  await sessions.saveMessage(user)8  await sessions.saveParts(parts)910  if (input.noReply) return { info: user, parts }按条件进入分支。11  return agentLoop(input.sessionID)返回给上一层。12}

这是教学骨架,不是源码逐行翻译。真实代码还会处理图片规范化、引用、MCP resource、agent mention、旧 tools 字段、实验事件系统与 revert cleanup。

5. 读源码前,只补两个数据模型

Section titled “5. 读源码前,只补两个数据模型”

5.1 Message 保存“这一轮是谁、用什么身份工作”

Section titled “5.1 Message 保存“这一轮是谁、用什么身份工作””

MessageV2.User 的核心字段包括:

1{2  id,3  sessionID,4  role: "user",5  time,6  agent,7  model: { providerID, modelID, variant },选择模型或 provider。8  system?,9  tools?,10  format?11}

真实 Schema 见 packages/opencode/src/session/message-v2.ts:327-350

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:327-350
327export const User = Schema.Struct({定义并校验数据形状。328  ...messageBase,329  role: Schema.Literal("user"),定义并校验数据形状。330  time: Schema.Struct({定义并校验数据形状。331    created: NonNegativeInt,332  }),333  format: Schema.optional(Format),定义并校验数据形状。334  summary: Schema.optional(定义并校验数据形状。335    Schema.Struct({定义并校验数据形状。336      title: Schema.optional(Schema.String),定义并校验数据形状。337      body: Schema.optional(Schema.String),定义并校验数据形状。338      diffs: Schema.Array(Snapshot.FileDiff),定义并校验数据形状。339    }),340  ),341  agent: Schema.String,定义并校验数据形状。342  model: Schema.Struct({定义并校验数据形状。343    providerID: ProviderID,选择模型或 provider。344    modelID: ModelID,选择模型或 provider。345    variant: Schema.optional(Schema.String),定义并校验数据形状。346  }),347  system: Schema.optional(Schema.String),定义并校验数据形状。348  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),定义并校验数据形状。349}).annotate({ identifier: "UserMessage" })350export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>定义并校验数据形状。

为什么 model 与 agent 要落在 message 上?因为历史可能跨越模型/agent 切换;下一轮若只看 session 的“当前配置”,就无法准确解释旧消息是在什么执行身份下产生的。

5.2 Part 保存“这一轮具体有哪些内容和状态”

Section titled “5.2 Part 保存“这一轮具体有哪些内容和状态””

MessageV2.Part 是以 type 为判别字段的 union,包含 text、file、tool、reasoning、step、patch、agent、compaction 等。来源:packages/opencode/src/session/message-v2.ts:352-378

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:352-378
352export const Part = Schema.Union([定义并校验数据形状。353  TextPart,会话消息片段结构。354  SubtaskPart,355  ReasoningPart,356  FilePart,357  ToolPart,会话消息片段结构。358  StepStartPart,359  StepFinishPart,360  SnapshotPart,361  PatchPart,362  AgentPart,363  RetryPart,364  CompactionPart,365]).annotate({ discriminator: "type", identifier: "Part" })366export type Part =定义数据结构约束。367  | TextPart会话消息片段结构。368  | SubtaskPart369  | ReasoningPart370  | FilePart371  | ToolPart会话消息片段结构。372  | StepStartPart373  | StepFinishPart374  | SnapshotPart375  | PatchPart376  | AgentPart377  | RetryPart378  | CompactionPart

本章输入端只接受四类草稿:TextPartInputFilePartInputAgentPartInputSubtaskPartInput。来源:packages/opencode/src/session/prompt.ts:1666-1688packages/opencode/src/session/message-v2.ts:390-450

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1666-1688
1666export const PromptInput = Schema.Struct({定义并校验数据形状。1667  sessionID: SessionID,1668  messageID: Schema.optional(MessageID),定义并校验数据形状。1669  model: Schema.optional(ModelRef),定义并校验数据形状。1670  agent: Schema.optional(Schema.String),定义并校验数据形状。1671  noReply: Schema.optional(Schema.Boolean),定义并校验数据形状。1672  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({定义并校验数据形状。1673    description:1674      "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",1675  }),1676  format: Schema.optional(MessageV2.Format),定义并校验数据形状。1677  system: Schema.optional(Schema.String),定义并校验数据形状。1678  variant: Schema.optional(Schema.String),定义并校验数据形状。1679  parts: Schema.Array(定义并校验数据形状。1680    Schema.Union([定义并校验数据形状。1681      MessageV2.TextPartInput,会话消息片段结构。1682      MessageV2.FilePartInput,会话消息片段结构。1683      MessageV2.AgentPartInput,会话消息片段结构。1684      MessageV2.SubtaskPartInput,会话消息片段结构。1685    ]).annotate({ discriminator: "type" }),1686  ),1687})1688export type PromptInput = Schema.Schema.Type<typeof PromptInput>定义并校验数据形状。
packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:390-450
390//391// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the392// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may393// omit `id` to let the server allocate one.  These Schema-Struct variants394// carry that shape so prompt decoding can accept drafts without stored IDs.395396export const TextPartInput = Schema.Struct({定义并校验数据形状。397  id: Schema.optional(PartID),定义并校验数据形状。398  type: Schema.Literal("text"),定义并校验数据形状。399  text: Schema.String,定义并校验数据形状。400  synthetic: Schema.optional(Schema.Boolean),定义并校验数据形状。401  ignored: Schema.optional(Schema.Boolean),定义并校验数据形状。402  time: Schema.optional(定义并校验数据形状。403    Schema.Struct({定义并校验数据形状。404      start: NonNegativeInt,405      end: Schema.optional(NonNegativeInt),定义并校验数据形状。406    }),407  ),408  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。409}).annotate({ identifier: "TextPartInput" })会话消息片段结构。410export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>定义并校验数据形状。411412export const FilePartInput = Schema.Struct({定义并校验数据形状。413  id: Schema.optional(PartID),定义并校验数据形状。414  type: Schema.Literal("file"),定义并校验数据形状。415  mime: Schema.String,定义并校验数据形状。416  filename: Schema.optional(Schema.String),定义并校验数据形状。417  url: Schema.String,定义并校验数据形状。418  source: Schema.optional(FilePartSource),定义并校验数据形状。419}).annotate({ identifier: "FilePartInput" })420export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>定义并校验数据形状。421422export const AgentPartInput = Schema.Struct({定义并校验数据形状。423  id: Schema.optional(PartID),定义并校验数据形状。424  type: Schema.Literal("agent"),定义并校验数据形状。425  name: Schema.String,定义并校验数据形状。426  source: Schema.optional(定义并校验数据形状。427    Schema.Struct({定义并校验数据形状。428      value: Schema.String,定义并校验数据形状。429      start: NonNegativeInt,430      end: NonNegativeInt,431    }),432  ),433}).annotate({ identifier: "AgentPartInput" })434export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>定义并校验数据形状。435436export const SubtaskPartInput = Schema.Struct({定义并校验数据形状。437  id: Schema.optional(PartID),定义并校验数据形状。438  type: Schema.Literal("subtask"),定义并校验数据形状。439  prompt: Schema.String,定义并校验数据形状。440  description: Schema.String,定义并校验数据形状。441  agent: Schema.String,定义并校验数据形状。442  model: Schema.optional(定义并校验数据形状。443    Schema.Struct({定义并校验数据形状。444      providerID: ProviderID,选择模型或 provider。445      modelID: ModelID,选择模型或 provider。446    }),447  ),448  command: Schema.optional(Schema.String),定义并校验数据形状。449}).annotate({ identifier: "SubtaskPartInput" })450export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>定义并校验数据形状。

这一区分很重要:存储模型比入口模型更丰富。工具状态、reasoning 等由后续 runtime 产生,不该伪装成用户可以随意提交的 part。

6. 追一条典型源码旅程:文本加本地文件

Section titled “6. 追一条典型源码旅程:文本加本地文件”

入口收到的典型 payload 可以抽象成:

1{2  agent: "build",3  model: { providerID: "...", modelID: "..." },选择模型或 provider。4  parts: [5    { type: "file", url: "file:///project/package.json", filename: "package.json", mime: "text/plain" },6    { type: "text", text: "解释这个项目" },7  ],8}

具体 provider/model 值由用户配置决定;这里不假设某一家模型。

6.1 第一站:API payload 从内部 input 派生

Section titled “6.1 第一站:API payload 从内部 input 派生”

HTTP 层没有另写一份易漂移的 DTO,而是从 SessionPrompt.PromptInput 去掉路径中已经提供的 sessionID

1export const PromptPayload = Schema.Struct(定义并校验数据形状。2  Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]),3)

来源:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66

packages/opencode/src/server/routes/instance/httpapi/groups/session.ts packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66
66export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))定义并校验数据形状。

endpoint 是 POST /session/:sessionID/message,成功类型为 MessageV2.WithParts。来源:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:74-101packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324

packages/opencode/src/server/routes/instance/httpapi/groups/session.ts packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:74-101
74export const SessionPaths = {对外暴露模块成员。75  list: root,76  status: `${root}/status`,77  get: `${root}/:sessionID`,78  children: `${root}/:sessionID/children`,79  todo: `${root}/:sessionID/todo`,80  diff: `${root}/:sessionID/diff`,81  messages: `${root}/:sessionID/message`,82  message: `${root}/:sessionID/message/:messageID`,83  create: root,84  remove: `${root}/:sessionID`,85  update: `${root}/:sessionID`,86  fork: `${root}/:sessionID/fork`,87  abort: `${root}/:sessionID/abort`,88  share: `${root}/:sessionID/share`,89  init: `${root}/:sessionID/init`,90  summarize: `${root}/:sessionID/summarize`,91  prompt: `${root}/:sessionID/message`,92  promptAsync: `${root}/:sessionID/prompt_async`,93  command: `${root}/:sessionID/command`,处理命令执行。94  shell: `${root}/:sessionID/shell`,处理命令执行。95  revert: `${root}/:sessionID/revert`,96  unrevert: `${root}/:sessionID/unrevert`,97  permissions: `${root}/:sessionID/permissions/:permissionID`,98  deleteMessage: `${root}/:sessionID/message/:messageID`,99  deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,100  updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,101} as const
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324
312        HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313          params: { sessionID: SessionID },314          query: WorkspaceRoutingQuery,315          payload: PromptPayload,316          success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317          error: [HttpApiError.BadRequest, ApiNotFoundError],318        }).annotateMerge(319          OpenApi.annotations({320            identifier: "session.prompt",把输入交给会话主流程。321            summary: "Send message",322            description: "Create and send a new message to a session, streaming the AI response.",323          }),324        ),
session.prompt 的 HTTP 合同 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324

URL 提供 sessionID,payload 提供本次消息草稿,返回值是带 parts 的消息。

312        HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313          params: { sessionID: SessionID },314          query: WorkspaceRoutingQuery,315          payload: PromptPayload,316          success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317          error: [HttpApiError.BadRequest, ApiNotFoundError],318        }).annotateMerge(319          OpenApi.annotations({320            identifier: "session.prompt",把输入交给会话主流程。321            summary: "Send message",322            description: "Create and send a new message to a session, streaming the AI response.",323          }),324        ),

这种派生减少了 API schema 与 service input 漂移的机会,但也让读者必须追到内部 Schema 才能看全请求形状。

6.2 第二站:handler 只补齐边界数据

Section titled “6.2 第二站:handler 只补齐边界数据”

handler 先确认 session 存在,再把 path param 中的 sessionID 与 payload 合并:

1const message = yield* promptSvc.prompt({等待 Effect 结果。2  ...ctx.payload,3  sessionID: ctx.params.sessionID,4})

来源:packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293
279    const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {Effect 异步工作流。280      params: { sessionID: SessionID }281      payload: typeof PromptPayload.Type282    }) {283      yield* requireSession(ctx.params.sessionID)等待 Effect 结果。284      const message = yield* promptSvc等待 Effect 结果。285        .prompt({286          ...ctx.payload,287          sessionID: ctx.params.sessionID,288        })289        .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。290      return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291        contentType: "application/json",292      })293    })

这是一层薄 adapter。它不解析文件、不选择模型,也不运行循环。同步 endpoint 最终把完整 message JSON 作为 stream response 返回;异步 endpoint 则 fork 执行并立即返回 No Content。来源:packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:290-314

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:290-314
290      return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291        contentType: "application/json",292      })293    })294295    const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {Effect 异步工作流。296      params: { sessionID: SessionID }297      payload: typeof PromptPayload.Type298    }) {299      yield* requireSession(ctx.params.sessionID)等待 Effect 结果。300      yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(等待 Effect 结果。301        Effect.catchCause((cause) =>Effect 异步工作流。302          Effect.gen(function* () {Effect 异步工作流。303            yield* Effect.logError("prompt_async failed").pipe(Effect 异步工作流。304              Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),Effect 异步工作流。305            )306            yield* bus.publish(Session.Event.Error, {广播状态变化。307              sessionID: ctx.params.sessionID,308              error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),309            })310          }),311        ),312        Effect.forkIn(scope, { startImmediately: true }),Effect 异步工作流。313      )314      return HttpApiSchema.NoContent.make()定义并校验数据形状。

6.3 第三站:createUserMessage 固化 agent 与 model

Section titled “6.3 第三站:createUserMessage 固化 agent 与 model”

createUserMessage 先解析 agent;若 agent 不存在,会发布 session error 并抛错。然后按以下优先级决定 model:

input.model
?? agent.model
?? currentModel(sessionID)

来源:packages/opencode/src/session/prompt.ts:689-715

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:689-715
689    const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690      const agentName = input.agent691      const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692      if (!ag) {按条件进入分支。693        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697        throw error失败时抛出错误。698      }699700      const current = Database.use((db) =>701        db702          .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703          .from(SessionTable)704          .where(eq(SessionTable.id, input.sessionID))705          .get(),706      )707      const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708      const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709      const full =710        !input.variant && ag.variant && same711          ? yield* provider选择模型或 provider。712              .getModel(model.providerID, model.modelID)选择模型或 provider。713              .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714          : undefined715      const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)

选定 variant 后,代码创建 MessageV2.User,写入 message id、session id、agent、model、system、format 等。来源:packages/opencode/src/session/prompt.ts:717-731

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:717-731
717      const info: MessageV2.User = {会话消息片段结构。718        id: input.messageID ?? MessageID.ascending(),719        role: "user",720        sessionID: input.sessionID,721        time: { created: Date.now() },722        tools: input.tools,723        agent: ag.name,724        model: {选择模型或 provider。725          providerID: model.providerID,选择模型或 provider。726          modelID: model.modelID,选择模型或 provider。727          variant,728        },729        system: input.system,730        format: input.format,731      }
user message 固化 agent/model packages/opencode/src/session/prompt.ts:689-731

重点看选择优先级,以及最终被保存的是解析后的执行身份。

689    const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690      const agentName = input.agent691      const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692      if (!ag) {按条件进入分支。693        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697        throw error失败时抛出错误。698      }699700      const current = Database.use((db) =>701        db702          .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703          .from(SessionTable)704          .where(eq(SessionTable.id, input.sessionID))705          .get(),706      )707      const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708      const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709      const full =710        !input.variant && ag.variant && same711          ? yield* provider选择模型或 provider。712              .getModel(model.providerID, model.modelID)选择模型或 provider。713              .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714          : undefined715      const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)716717      const info: MessageV2.User = {会话消息片段结构。718        id: input.messageID ?? MessageID.ascending(),719        role: "user",720        sessionID: input.sessionID,721        time: { created: Date.now() },722        tools: input.tools,723        agent: ag.name,724        model: {选择模型或 provider。725          providerID: model.providerID,选择模型或 provider。726          modelID: model.modelID,选择模型或 provider。727          variant,728        },729        system: input.system,730        format: input.format,731      }

这里还会在 agent/model 与 session 当前值不同时发布切换事件。来源:packages/opencode/src/session/prompt.ts:733-754。这是 UI/事件层的可观察性,不改变上述选择优先级。

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:733-754
733      if (current?.agent !== info.agent) {按条件进入分支。734        yield* events.publish(SessionEvent.AgentSwitched, {广播状态变化。735          sessionID: input.sessionID,736          timestamp: DateTime.makeUnsafe(info.time.created),737          agent: info.agent,738        })739      }740      if (按条件进入分支。741        current?.model?.providerID !== info.model.providerID ||选择模型或 provider。742        current.model.id !== info.model.modelID ||选择模型或 provider。743        (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant744      ) {745        yield* events.publish(SessionEvent.ModelSwitched, {广播状态变化。746          sessionID: input.sessionID,747          timestamp: DateTime.makeUnsafe(info.time.created),748          model: {选择模型或 provider。749            id: ModelV2.ID.make(info.model.modelID),选择模型或 provider。750            providerID: ProviderV2.ID.make(info.model.providerID),选择模型或 provider。751            variant: ModelV2.VariantID.make(info.model.variant ?? "default"),752          },753        })754      }

6.4 第四站:text part 只补 ambient IDs

Section titled “6.4 第四站:text part 只补 ambient IDs”

普通 text part 不需要 I/O。resolvePart 的兜底分支把输入字段保留,并补上 messageIDsessionID;随后 assign 在缺少 part id 时生成新 ID。来源:packages/opencode/src/session/prompt.ts:758-762packages/opencode/src/session/prompt.ts:1062-1067

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:758-762
758      type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never会话消息片段结构。759      const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({会话消息片段结构。760        ...part,761        id: part.id ? PartID.make(part.id) : PartID.ascending(),762      })
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1062-1067
1062        return [{ ...part, messageID: info.id, sessionID: input.sessionID }]返回给上一层。1063      })10641065      const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066        Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067      )

这说明输入 part 是 draft,存储 part 是带完整归属信息的事实。

6.5 第五站:file part 在入账前展开成模型可读上下文

Section titled “6.5 第五站:file part 在入账前展开成模型可读上下文”

file: URL,代码把 URL 转成本地路径,判断真实 MIME/目录类型,并取得 registry 中的 read 工具。来源:packages/opencode/src/session/prompt.ts:844-888

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:844-888
844          const url = new URL(part.url)845          switch (url.protocol) {846            case "data:":847              if (part.mime === "text/plain") {按条件进入分支。848                return [返回给上一层。849                  {850                    messageID: info.id,851                    sessionID: input.sessionID,852                    type: "text",853                    synthetic: true,854                    text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,855                  },856                  {857                    messageID: info.id,858                    sessionID: input.sessionID,859                    type: "text",860                    synthetic: true,861                    text: decodeDataUrl(part.url),862                  },863                  { ...part, messageID: info.id, sessionID: input.sessionID },864                ]865              }866              break867            case "file:": {868              log.info("file", { mime: part.mime })869              const filepath = fileURLToPath(part.url)870              const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871              const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873              const { read } = yield* registry.named()等待 Effect 结果。874              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875                const controller = new AbortController()用于中断运行任务。876                return read返回给上一层。877                  .execute(args, {工具真正执行入口。878                    sessionID: input.sessionID,879                    abort: controller.signal,880                    agent: input.agent!,881                    messageID: info.id,882                    extra: { bypassCwdCheck: true, ...extra },883                    messages: [],884                    metadata: () => Effect.void,Effect 异步工作流。885                    ask: () => Effect.void,Effect 异步工作流。886                  })887                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888              }

若是 text/plain,典型成功结果会产生:

  1. 一条 synthetic text,记录等价的 Read 调用参数;
  2. 一条 synthetic text,保存 read 工具输出;
  3. 原 file part,或 read 工具返回的附件;
  4. 若命中 reference,还会增加 reference context。

证据见 packages/opencode/src/session/prompt.ts:890-969

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:890-969
890              if (mime === "text/plain") {按条件进入分支。891                let offset: number | undefined892                let limit: number | undefined893                const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894                if (range.start != null) {按条件进入分支。895                  const filePathURI = part.url.split("?")[0]896                  let start = parseInt(range.start)897                  let end = range.end ? parseInt(range.end) : undefined898                  if (start === end) {按条件进入分支。899                    const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900                    for (const symbol of symbols) {遍历集合。901                      let r: LSP.Range | undefined处理语言服务诊断。902                      if ("range" in symbol) r = symbol.range按条件进入分支。903                      else if ("location" in symbol) r = symbol.location.range904                      if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905                        start = r.start.line906                        end = r?.end?.line ?? start907                        break908                      }909                    }910                  }911                  offset = Math.max(start, 1)912                  if (end) limit = end - (offset - 1)按条件进入分支。913                }914                const args = { filePath: filepath, offset, limit }915                const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916                  ...(referenceContext917                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918                    : []),919                  {920                    messageID: info.id,921                    sessionID: input.sessionID,922                    type: "text",923                    synthetic: true,924                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925                  },926                ]927                const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928                  Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929                  Effect.exit,Effect 异步工作流。930                )931                if (Exit.isSuccess(exit)) {按条件进入分支。932                  const result = exit.value933                  pieces.push({934                    messageID: info.id,935                    sessionID: input.sessionID,936                    type: "text",937                    synthetic: true,938                    text: result.output,939                  })940                  if (result.attachments?.length) {按条件进入分支。941                    pieces.push(942                      ...result.attachments.map((a) => ({943                        ...a,944                        synthetic: true,945                        filename: a.filename ?? part.filename,946                        messageID: info.id,947                        sessionID: input.sessionID,948                      })),949                    )950                  } else {951                    pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952                  }953                } else {954                  const error = Cause.squash(exit.cause)955                  log.error("failed to read file", { error })956                  const message = error instanceof Error ? error.message : String(error)957                  yield* bus.publish(Session.Event.Error, {广播状态变化。958                    sessionID: input.sessionID,959                    error: new NamedError.Unknown({ message }).toObject(),960                  })961                  pieces.push({962                    messageID: info.id,963                    sessionID: input.sessionID,964                    type: "text",965                    synthetic: true,966                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,967                  })968                }969                return pieces返回给上一层。
本地文本附件怎样展开 packages/opencode/src/session/prompt.ts:867-969

不要只看 file part;真正进入上下文的还有 synthetic Read 记录和读取结果。

867            case "file:": {868              log.info("file", { mime: part.mime })869              const filepath = fileURLToPath(part.url)870              const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871              const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873              const { read } = yield* registry.named()等待 Effect 结果。874              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875                const controller = new AbortController()用于中断运行任务。876                return read返回给上一层。877                  .execute(args, {工具真正执行入口。878                    sessionID: input.sessionID,879                    abort: controller.signal,880                    agent: input.agent!,881                    messageID: info.id,882                    extra: { bypassCwdCheck: true, ...extra },883                    messages: [],884                    metadata: () => Effect.void,Effect 异步工作流。885                    ask: () => Effect.void,Effect 异步工作流。886                  })887                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888              }889890              if (mime === "text/plain") {按条件进入分支。891                let offset: number | undefined892                let limit: number | undefined893                const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894                if (range.start != null) {按条件进入分支。895                  const filePathURI = part.url.split("?")[0]896                  let start = parseInt(range.start)897                  let end = range.end ? parseInt(range.end) : undefined898                  if (start === end) {按条件进入分支。899                    const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900                    for (const symbol of symbols) {遍历集合。901                      let r: LSP.Range | undefined处理语言服务诊断。902                      if ("range" in symbol) r = symbol.range按条件进入分支。903                      else if ("location" in symbol) r = symbol.location.range904                      if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905                        start = r.start.line906                        end = r?.end?.line ?? start907                        break908                      }909                    }910                  }911                  offset = Math.max(start, 1)912                  if (end) limit = end - (offset - 1)按条件进入分支。913                }914                const args = { filePath: filepath, offset, limit }915                const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916                  ...(referenceContext917                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918                    : []),919                  {920                    messageID: info.id,921                    sessionID: input.sessionID,922                    type: "text",923                    synthetic: true,924                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925                  },926                ]927                const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928                  Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929                  Effect.exit,Effect 异步工作流。930                )931                if (Exit.isSuccess(exit)) {按条件进入分支。932                  const result = exit.value933                  pieces.push({934                    messageID: info.id,935                    sessionID: input.sessionID,936                    type: "text",937                    synthetic: true,938                    text: result.output,939                  })940                  if (result.attachments?.length) {按条件进入分支。941                    pieces.push(942                      ...result.attachments.map((a) => ({943                        ...a,944                        synthetic: true,945                        filename: a.filename ?? part.filename,946                        messageID: info.id,947                        sessionID: input.sessionID,948                      })),949                    )950                  } else {951                    pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952                  }953                } else {954                  const error = Cause.squash(exit.cause)955                  log.error("failed to read file", { error })956                  const message = error instanceof Error ? error.message : String(error)957                  yield* bus.publish(Session.Event.Error, {广播状态变化。958                    sessionID: input.sessionID,959                    error: new NamedError.Unknown({ message }).toObject(),960                  })961                  pieces.push({962                    messageID: info.id,963                    sessionID: input.sessionID,964                    type: "text",965                    synthetic: true,966                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,967                  })968                }969                return pieces返回给上一层。

这里的 read.execute 使用 bypassCwdCheck: trueask 也是空 Effect。准确的边界是:这是用户主动提交附件后的预解析路径,不是模型自主调用 read 工具的普通权限路径。来源:packages/opencode/src/session/prompt.ts:873-887

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:873-887
873              const { read } = yield* registry.named()等待 Effect 结果。874              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875                const controller = new AbortController()用于中断运行任务。876                return read返回给上一层。877                  .execute(args, {工具真正执行入口。878                    sessionID: input.sessionID,879                    abort: controller.signal,880                    agent: input.agent!,881                    messageID: info.id,882                    extra: { bypassCwdCheck: true, ...extra },883                    messages: [],884                    metadata: () => Effect.void,Effect 异步工作流。885                    ask: () => Effect.void,Effect 异步工作流。886                  })887                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。

为什么不只保存路径?因为下一轮模型需要可消费的内容,session 恢复也不能依赖模型自己“知道去读”。同时保留 file part,又能让 UI 与支持附件的 provider 识别原始附件语义。

6.6 第六站:所有 parts 并发解析,再交给插件

Section titled “6.6 第六站:所有 parts 并发解析,再交给插件”

Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }) 并发解析输入,再 flatten 并分配 ID。之后触发 chat.message

1yield* plugin.trigger(调用插件扩展点。2  "chat.message",3  { sessionID, agent, model, messageID, variant },4  { message: info, parts: resolvedParts },5)

来源:packages/opencode/src/session/prompt.ts:1065-1079

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1065-1079
1065      const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066        Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067      )10681069      yield* plugin.trigger(调用插件扩展点。1070        "chat.message",1071        {1072          sessionID: input.sessionID,1073          agent: input.agent,1074          model: input.model,选择模型或 provider。1075          messageID: input.messageID,1076          variant: input.variant,1077        },1078        { message: info, parts: resolvedParts },1079      )

插件拿到的是可修改对象,并且 hook 发生在保存之前。因此它是消息入账边界上的扩展点,而不是保存后的旁路补丁。

6.7 第七站:先保存 message,再逐个保存 parts

Section titled “6.7 第七站:先保存 message,再逐个保存 parts”

图片经过可选规范化后,源码执行:

1yield* sessions.updateMessage(info)等待 Effect 结果。2for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。

来源:packages/opencode/src/session/prompt.ts:1081-1117

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1081-1117
1081      const parts = yield* Effect.forEach(resolvedParts, (part) =>Effect 异步工作流。1082        part.type === "file" && part.mime.startsWith("image/")1083          ? image.normalize(part).pipe(1084              Effect.catchIf(Effect 异步工作流。1085                (error) => error instanceof Image.ResizerUnavailableError,1086                () => Effect.succeed(part),Effect 异步工作流。1087              ),1088            )1089          : Effect.succeed(part),Effect 异步工作流。1090      )10911092      const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })1093      if (Exit.isFailure(parsed)) {按条件进入分支。1094        log.error("invalid user message before save", {1095          sessionID: input.sessionID,1096          messageID: info.id,1097          agent: info.agent,1098          model: info.model,选择模型或 provider。1099          cause: Cause.pretty(parsed.cause),1100        })1101      }1102      parts.forEach((part, index) => {1103        const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104        if (Exit.isSuccess(p)) return按条件进入分支。1105        log.error("invalid user part before save", {1106          sessionID: input.sessionID,1107          messageID: info.id,1108          partID: part.id,1109          partType: part.type,1110          index,1111          cause: Cause.pretty(p.cause),1112          part,1113        })1114      })11151116      yield* sessions.updateMessage(info)等待 Effect 结果。1117      for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。
校验并持久化消息与 parts packages/opencode/src/session/prompt.ts:1102-1117

入库前会记录无效 part,随后 message 和每个 part 进入 Session.Service。

1102      parts.forEach((part, index) => {1103        const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104        if (Exit.isSuccess(p)) return按条件进入分支。1105        log.error("invalid user part before save", {1106          sessionID: input.sessionID,1107          messageID: info.id,1108          partID: part.id,1109          partType: part.type,1110          index,1111          cause: Cause.pretty(p.cause),1112          part,1113        })1114      })11151116      yield* sessions.updateMessage(info)等待 Effect 结果。1117      for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。

为什么分开保存?从数据形状可以确认,这允许 part 独立更新;工具状态与流式文本正需要这种粒度。更底层具体数据库事务语义不在本章所列证据中,因此这里不推断它们必然原子提交。

6.8 第八站:记账后才决定是否运行

Section titled “6.8 第八站:记账后才决定是否运行”

SessionPrompt.prompt 先清理 revert、创建消息、touch session,再把旧 input.tools 映射为 session permission。最后:

1if (input.noReply === true) return message按条件进入分支。2return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。

来源:packages/opencode/src/session/prompt.ts:1211-1229

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1211-1229
1211    const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212      "SessionPrompt.prompt",把输入交给会话主流程。1213    )(function* (input: PromptInput) {1214      const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215      yield* revert.cleanup(session)等待 Effect 结果。1216      const message = yield* createUserMessage(input)等待 Effect 结果。1217      yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219      const permissions: Permission.Ruleset = []1220      for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221        permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222      }1223      if (permissions.length > 0) {按条件进入分支。1224        session.permission = permissions1225        yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226      }12271228      if (input.noReply === true) return message按条件进入分支。1229      return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。

所以 noReply 不是“不保存”,而是“只入账,不启动回复循环”。这对于批量导入、异步编排或先构造上下文很有价值。

7.1 文件读取失败不会伪装成成功

Section titled “7.1 文件读取失败不会伪装成成功”

本地文本读取失败时,代码发布 session error,并增加一条 synthetic text 描述读取失败;不会凭空制造文件内容。来源:packages/opencode/src/session/prompt.ts:953-968

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:953-968
953                } else {954                  const error = Cause.squash(exit.cause)955                  log.error("failed to read file", { error })956                  const message = error instanceof Error ? error.message : String(error)957                  yield* bus.publish(Session.Event.Error, {广播状态变化。958                    sessionID: input.sessionID,959                    error: new NamedError.Unknown({ message }).toObject(),960                  })961                  pieces.push({962                    messageID: info.id,963                    sessionID: input.sessionID,964                    type: "text",965                    synthetic: true,966                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,967                  })968                }

这使失败本身也成为后续可见上下文,但调用方仍应把 session error 当成错误信号。

7.2 输入 union 限制“用户能声称什么”

Section titled “7.2 输入 union 限制“用户能声称什么””

PromptInput 只接收 text/file/agent/subtask drafts,不能直接提交一个已经 completed 的 ToolPart。来源:packages/opencode/src/session/prompt.ts:1666-1688。这防止外部请求把 runtime 状态伪造成用户输入。

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1666-1688
1666export const PromptInput = Schema.Struct({定义并校验数据形状。1667  sessionID: SessionID,1668  messageID: Schema.optional(MessageID),定义并校验数据形状。1669  model: Schema.optional(ModelRef),定义并校验数据形状。1670  agent: Schema.optional(Schema.String),定义并校验数据形状。1671  noReply: Schema.optional(Schema.Boolean),定义并校验数据形状。1672  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({定义并校验数据形状。1673    description:1674      "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",1675  }),1676  format: Schema.optional(MessageV2.Format),定义并校验数据形状。1677  system: Schema.optional(Schema.String),定义并校验数据形状。1678  variant: Schema.optional(Schema.String),定义并校验数据形状。1679  parts: Schema.Array(定义并校验数据形状。1680    Schema.Union([定义并校验数据形状。1681      MessageV2.TextPartInput,会话消息片段结构。1682      MessageV2.FilePartInput,会话消息片段结构。1683      MessageV2.AgentPartInput,会话消息片段结构。1684      MessageV2.SubtaskPartInput,会话消息片段结构。1685    ]).annotate({ discriminator: "type" }),1686  ),1687})1688export type PromptInput = Schema.Schema.Type<typeof PromptInput>定义并校验数据形状。

7.3 MCP resource 与本地文件是两条读取路径

Section titled “7.3 MCP resource 与本地文件是两条读取路径”

若 file part 的 source 是 resourceresolvePartmcp.readResource;本地 file: URL 则走 registry 的 read 工具。来源:packages/opencode/src/session/prompt.ts:791-842packages/opencode/src/session/prompt.ts:844-1040。它们最终都归一为 typed parts,但信任边界与失败信息不同。

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:791-842
791        if (part.type === "file") {按条件进入分支。792          if (part.source?.type === "resource") {按条件进入分支。793            const { clientName, uri } = part.source794            log.info("mcp resource", { clientName, uri, mime: part.mime })795            const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。796              {797                messageID: info.id,798                sessionID: input.sessionID,799                type: "text",800                synthetic: true,801                text: `Reading MCP resource: ${part.filename} (${uri})`,802              },803            ]804            const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit)Effect 异步工作流。805            if (Exit.isSuccess(exit)) {按条件进入分支。806              const content = exit.value807              if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)按条件进入分支。808              const items = Array.isArray(content.contents) ? content.contents : [content.contents]809              for (const c of items) {遍历集合。810                if ("text" in c && c.text) {按条件进入分支。811                  pieces.push({812                    messageID: info.id,813                    sessionID: input.sessionID,814                    type: "text",815                    synthetic: true,816                    text: c.text,817                  })818                } else if ("blob" in c && c.blob) {819                  const mime = "mimeType" in c ? c.mimeType : part.mime820                  pieces.push({821                    messageID: info.id,822                    sessionID: input.sessionID,823                    type: "text",824                    synthetic: true,825                    text: `[Binary content: ${mime}]`,826                  })827                }828              }829              pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })830            } else {831              const error = Cause.squash(exit.cause)832              log.error("failed to read MCP resource", { error, clientName, uri })833              const message = error instanceof Error ? error.message : String(error)834              pieces.push({835                messageID: info.id,836                sessionID: input.sessionID,837                type: "text",838                synthetic: true,839                text: `Failed to read MCP resource ${part.filename}: ${message}`,840              })841            }842            return pieces返回给上一层。
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:844-1040
844          const url = new URL(part.url)845          switch (url.protocol) {846            case "data:":847              if (part.mime === "text/plain") {按条件进入分支。848                return [返回给上一层。849                  {850                    messageID: info.id,851                    sessionID: input.sessionID,852                    type: "text",853                    synthetic: true,854                    text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,855                  },856                  {857                    messageID: info.id,858                    sessionID: input.sessionID,859                    type: "text",860                    synthetic: true,861                    text: decodeDataUrl(part.url),862                  },863                  { ...part, messageID: info.id, sessionID: input.sessionID },864                ]865              }866              break867            case "file:": {868              log.info("file", { mime: part.mime })869              const filepath = fileURLToPath(part.url)870              const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871              const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873              const { read } = yield* registry.named()等待 Effect 结果。874              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875                const controller = new AbortController()用于中断运行任务。876                return read返回给上一层。877                  .execute(args, {工具真正执行入口。878                    sessionID: input.sessionID,879                    abort: controller.signal,880                    agent: input.agent!,881                    messageID: info.id,882                    extra: { bypassCwdCheck: true, ...extra },883                    messages: [],884                    metadata: () => Effect.void,Effect 异步工作流。885                    ask: () => Effect.void,Effect 异步工作流。886                  })887                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888              }889890              if (mime === "text/plain") {按条件进入分支。891                let offset: number | undefined892                let limit: number | undefined893                const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894                if (range.start != null) {按条件进入分支。895                  const filePathURI = part.url.split("?")[0]896                  let start = parseInt(range.start)897                  let end = range.end ? parseInt(range.end) : undefined898                  if (start === end) {按条件进入分支。899                    const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900                    for (const symbol of symbols) {遍历集合。901                      let r: LSP.Range | undefined处理语言服务诊断。902                      if ("range" in symbol) r = symbol.range按条件进入分支。903                      else if ("location" in symbol) r = symbol.location.range904                      if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905                        start = r.start.line906                        end = r?.end?.line ?? start907                        break908                      }909                    }910                  }911                  offset = Math.max(start, 1)912                  if (end) limit = end - (offset - 1)按条件进入分支。913                }914                const args = { filePath: filepath, offset, limit }915                const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916                  ...(referenceContext917                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918                    : []),919                  {920                    messageID: info.id,921                    sessionID: input.sessionID,922                    type: "text",923                    synthetic: true,924                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925                  },926                ]927                const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928                  Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929                  Effect.exit,Effect 异步工作流。930                )931                if (Exit.isSuccess(exit)) {按条件进入分支。932                  const result = exit.value933                  pieces.push({934                    messageID: info.id,935                    sessionID: input.sessionID,936                    type: "text",937                    synthetic: true,938                    text: result.output,939                  })940                  if (result.attachments?.length) {按条件进入分支。941                    pieces.push(942                      ...result.attachments.map((a) => ({943                        ...a,944                        synthetic: true,945                        filename: a.filename ?? part.filename,946                        messageID: info.id,947                        sessionID: input.sessionID,948                      })),949                    )950                  } else {951                    pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952                  }953                } else {954                  const error = Cause.squash(exit.cause)955                  log.error("failed to read file", { error })956                  const message = error instanceof Error ? error.message : String(error)957                  yield* bus.publish(Session.Event.Error, {广播状态变化。958                    sessionID: input.sessionID,959                    error: new NamedError.Unknown({ message }).toObject(),960                  })961                  pieces.push({962                    messageID: info.id,963                    sessionID: input.sessionID,964                    type: "text",965                    synthetic: true,966                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,967                  })968                }969                return pieces返回给上一层。970              }971972              if (mime === "application/x-directory") {按条件进入分支。973                const args = { filePath: filepath }974                const exit = yield* execRead(args).pipe(Effect.exit)Effect 异步工作流。975                if (Exit.isFailure(exit)) {按条件进入分支。976                  const error = Cause.squash(exit.cause)977                  log.error("failed to read directory", { error })978                  const message = error instanceof Error ? error.message : String(error)979                  yield* bus.publish(Session.Event.Error, {广播状态变化。980                    sessionID: input.sessionID,981                    error: new NamedError.Unknown({ message }).toObject(),982                  })983                  return [返回给上一层。984                    ...(referenceContext985                      ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]986                      : []),987                    {988                      messageID: info.id,989                      sessionID: input.sessionID,990                      type: "text",991                      synthetic: true,992                      text: `Read tool failed to read ${filepath} with the following error: ${message}`,993                    },994                  ]995                }996                return [返回给上一层。997                  ...(referenceContext998                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]999                    : []),1000                  {1001                    messageID: info.id,1002                    sessionID: input.sessionID,1003                    type: "text",1004                    synthetic: true,1005                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,1006                  },1007                  {1008                    messageID: info.id,1009                    sessionID: input.sessionID,1010                    type: "text",1011                    synthetic: true,1012                    text: exit.value.output,1013                  },1014                  { ...part, mime, messageID: info.id, sessionID: input.sessionID },1015                ]1016              }10171018              return [返回给上一层。1019                ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []),1020                {1021                  messageID: info.id,1022                  sessionID: input.sessionID,1023                  type: "text",1024                  synthetic: true,1025                  text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,1026                },1027                {1028                  id: part.id,1029                  messageID: info.id,1030                  sessionID: input.sessionID,1031                  type: "file",1032                  url:1033                    `data:${mime};base64,` +1034                    Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),读写本地文件。1035                  mime,1036                  filename: part.filename!,1037                  source: part.source,1038                },1039              ]1040            }

7.4 Prompted / Synthetic 当前是条件双写

Section titled “7.4 Prompted / Synthetic 当前是条件双写”

持久化后,代码把普通文本、文件、agent、reference 与 synthetic text 聚合;只有 experimentalEventSystem 开启时才发布 SessionEvent.PromptedSessionEvent.Synthetic。源码注释明确称这是迁移期间的 temporary dual-write。来源:packages/opencode/src/session/prompt.ts:1118-1206

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1118-1206
1118      const nextPrompt = parts.reduce(1119        (result, part) => {1120          if (part.type === "text") {按条件进入分支。1121            if (part.synthetic) result.synthetic.push(part.text)按条件进入分支。1122            else result.text.push(part.text)1123            const reference = referencePromptMetadata(part.metadata?.reference)1124            if (reference) {按条件进入分支。1125              result.references.push(1126                new ReferenceAttachment({1127                  name: reference.name,1128                  kind: reference.kind,1129                  uri: reference.path ? pathToFileURL(reference.path).href : undefined,1130                  repository: reference.repository,1131                  branch: reference.branch,1132                  target: reference.target,1133                  targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,1134                  problem: reference.problem,1135                  source: new Source({1136                    start: reference.source.start,1137                    end: reference.source.end,1138                    text: reference.source.value,1139                  }),1140                }),1141              )1142            }1143          }1144          if (part.type === "file") {按条件进入分支。1145            result.files.push(1146              new FileAttachment({1147                uri: part.url,1148                mime: part.mime,1149                name: part.filename,1150                source: part.source1151                  ? new Source({1152                      start: part.source.text.start,1153                      end: part.source.text.end,1154                      text: part.source.text.value,1155                    })1156                  : undefined,1157              }),1158            )1159          }1160          if (part.type === "agent") {按条件进入分支。1161            result.agents.push(1162              new AgentAttachment({1163                name: part.name,1164                source: part.source1165                  ? new Source({1166                      start: part.source.start,1167                      end: part.source.end,1168                      text: part.source.value,1169                    })1170                  : undefined,1171              }),1172            )1173          }1174          return result返回给上一层。1175        },1176        {1177          text: [] as string[],1178          files: [] as FileAttachment[],1179          agents: [] as AgentAttachment[],1180          references: [] as ReferenceAttachment[],1181          synthetic: [] as string[],1182        },1183      )1184      // TODO(v2): Temporary dual-write while migrating session messages to v2 events.1185      if (flags.experimentalEventSystem) {按条件进入分支。1186        yield* events.publish(SessionEvent.Prompted, {广播状态变化。1187          sessionID: input.sessionID,1188          timestamp: DateTime.makeUnsafe(info.time.created),1189          prompt: {1190            text: nextPrompt.text.join("\n"),1191            files: nextPrompt.files,1192            agents: nextPrompt.agents,1193            references: nextPrompt.references,1194          },1195        })1196      }1197      for (const text of nextPrompt.synthetic) {遍历集合。1198        // TODO(v2): Temporary dual-write while migrating session messages to v2 events.1199        if (flags.experimentalEventSystem) {按条件进入分支。1200          yield* events.publish(SessionEvent.Synthetic, {广播状态变化。1201            sessionID: input.sessionID,1202            timestamp: DateTime.makeUnsafe(info.time.created),1203            text,1204          })1205        }1206      }

因此不能把这些实验事件描述成所有环境下唯一的消息存储。

8. OpenCode 的选择:替代方案与代价

Section titled “8. OpenCode 的选择:替代方案与代价”
设计问题OpenCode 的选择可选做法收益与代价
一条消息怎样建模message 元信息 + typed parts单一 content 字符串可表达附件与状态;模型和转换更复杂
API schema 从哪里来从 PromptInput omit sessionID维护独立 DTO减少漂移;外部合同与内部类型耦合更紧
附件何时读取user message 创建阶段展开等模型自己调用 read首轮就有确定上下文;入口阶段 I/O 更重
插件何时修改解析后、保存前保存后再补写入账内容一致;插件可影响核心输入,需谨慎治理
先保存还是先运行先持久化,再进 loop在内存里跑完再保存可恢复、可观察;要处理部分完成和失败记录

表中的行为由源码直接证明;收益与代价是基于结构的设计解释。

9. TypeScript / Effect:只学会挡路的三处

Section titled “9. TypeScript / Effect:只学会挡路的三处”

part.type === "file" 后,TypeScript 能把 part 缩小到 FilePartInput。可以类比 Java sealed interface + pattern matching;不同点是 Effect Schema 同时做运行时解码。

它从现有 Schema 的 fields 派生新结构,不只是 TypeScript 编译期的 Omit<T, K>。因此 API 层获得的仍是运行时可校验 Schema。

createUserMessage 被包在 scope 中,并注册 finalizer 清理 instruction 状态。可以暂时理解为结构化资源作用域;不要把它等同于普通 finally,因为 scope 可管理多个 Effect 资源。

方法一:把外部输入转换成稳定领域事实

Section titled “方法一:把外部输入转换成稳定领域事实”

先解析默认值、分配 ID、补归属信息,再持久化;不要让后续 loop 反复解释原始 HTTP DTO。

验证问题:换成 IDE 客户端后,agent loop 是否仍只读取同一种 message/part?

方法二:让入口类型小于内部状态空间

Section titled “方法二:让入口类型小于内部状态空间”

用户只能提交允许的 drafts;tool/reasoning/step 状态只能由 runtime 产生。

验证问题:外部调用者能否伪造一条“工具已成功执行”的记录?

方法三:把可恢复性设计进写入顺序

Section titled “方法三:把可恢复性设计进写入顺序”

先保存用户委托,再启动长时间运行的 agent loop。

验证问题:模型请求前进程中断,重启后还能否看见并继续这条委托?

不要看上文,补全:

session 是 ______,message 是 ______,part 是 ______。HTTP payload 从 ______ 派生。createUserMessage 先选择 ______,再解析 ______。本地文本附件会产生 ______,保存完成后,除非 ______,否则进入 agent loop。

如果你在“file part 是否就是文件正文”处犹豫,请回看 packages/opencode/src/session/prompt.ts:867-969

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:867-969
867            case "file:": {868              log.info("file", { mime: part.mime })869              const filepath = fileURLToPath(part.url)870              const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871              const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873              const { read } = yield* registry.named()等待 Effect 结果。874              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875                const controller = new AbortController()用于中断运行任务。876                return read返回给上一层。877                  .execute(args, {工具真正执行入口。878                    sessionID: input.sessionID,879                    abort: controller.signal,880                    agent: input.agent!,881                    messageID: info.id,882                    extra: { bypassCwdCheck: true, ...extra },883                    messages: [],884                    metadata: () => Effect.void,Effect 异步工作流。885                    ask: () => Effect.void,Effect 异步工作流。886                  })887                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888              }889890              if (mime === "text/plain") {按条件进入分支。891                let offset: number | undefined892                let limit: number | undefined893                const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894                if (range.start != null) {按条件进入分支。895                  const filePathURI = part.url.split("?")[0]896                  let start = parseInt(range.start)897                  let end = range.end ? parseInt(range.end) : undefined898                  if (start === end) {按条件进入分支。899                    const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900                    for (const symbol of symbols) {遍历集合。901                      let r: LSP.Range | undefined处理语言服务诊断。902                      if ("range" in symbol) r = symbol.range按条件进入分支。903                      else if ("location" in symbol) r = symbol.location.range904                      if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905                        start = r.start.line906                        end = r?.end?.line ?? start907                        break908                      }909                    }910                  }911                  offset = Math.max(start, 1)912                  if (end) limit = end - (offset - 1)按条件进入分支。913                }914                const args = { filePath: filepath, offset, limit }915                const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916                  ...(referenceContext917                    ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918                    : []),919                  {920                    messageID: info.id,921                    sessionID: input.sessionID,922                    type: "text",923                    synthetic: true,924                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925                  },926                ]927                const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928                  Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929                  Effect.exit,Effect 异步工作流。930                )931                if (Exit.isSuccess(exit)) {按条件进入分支。932                  const result = exit.value933                  pieces.push({934                    messageID: info.id,935                    sessionID: input.sessionID,936                    type: "text",937                    synthetic: true,938                    text: result.output,939                  })940                  if (result.attachments?.length) {按条件进入分支。941                    pieces.push(942                      ...result.attachments.map((a) => ({943                        ...a,944                        synthetic: true,945                        filename: a.filename ?? part.filename,946                        messageID: info.id,947                        sessionID: input.sessionID,948                      })),949                    )950                  } else {951                    pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952                  }953                } else {954                  const error = Cause.squash(exit.cause)955                  log.error("failed to read file", { error })956                  const message = error instanceof Error ? error.message : String(error)957                  yield* bus.publish(Session.Event.Error, {广播状态变化。958                    sessionID: input.sessionID,959                    error: new NamedError.Unknown({ message }).toObject(),960                  })961                  pieces.push({962                    messageID: info.id,963                    sessionID: input.sessionID,964                    type: "text",965                    synthetic: true,966                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,967                  })968                }969                return pieces返回给上一层。
  1. 入门:列出 PromptInput.parts 允许的四种输入类型。
  2. 进阶:画出 model 的三层选择优先级,并解释 variant 何时继承 agent 默认值。
  3. 辨析:比较 stored FilePart 与解析产生的 synthetic TextPart。
  4. 失败路径:追踪本地文件读取失败后,bus event 与 parts 分别发生什么变化。

写一个内存版会话层:输入只支持 text/file drafts,保存 UserMessagePart[];file 读取成功和失败都必须产生可审计 part;noReply 时只保存不调用假模型。

12. 最后复盘:账本已经准备好,谁来推进它

Section titled “12. 最后复盘:账本已经准备好,谁来推进它”

本章主链是:

PromptInput -> 选择 agent/model -> 解析 typed parts -> plugin -> 保存 -> loop

你现在应该能回答中心问题:文本和附件没有被揉成一个字符串,而是被转换为带身份、归属、来源与类型的持久化事实;后续 agent 才能从同一本账本重建上下文。

下一章要解决的悬念是:账本已经有了 user message,模型第一次只选择工具而不回答时,谁负责执行、回填并再次调用模型?这就是“Agent 核心循环”。