跳到主内容
创建时间:最后更新:作者:白川

保存扩展状态并处理会话变化

把检查结果写入自定义会话条目,从当前分支恢复,避免状态串到其他会话。

保存扩展状态并处理会话变化

在模块变量里保存最近一次检查,当前进程里很好用。重启后它消失,切换分支后它又可能指向另一个分支的文件。状态问题的关键不是选一个全局 Map,而是确定这份结果属于哪个会话、哪条分支。

本文让检查报告跟随当前会话分支保存。内存只作为临时缓存,恢复时以会话记录为准。

本文示例固定使用 @earendil-works/pi-coding-agent@1.5.0,源码位于仓库 columns/coding-agent/examples/extension-development/。示例是独立包,先在该目录安装依赖;不需要修改专栏站点依赖。下面的命令均从这个示例目录运行。

保存的数据要能重新验证

src/state.ts 为持久化数据加上独立类型名,并在恢复时验证。TypeScript 的 Report 类型不能让磁盘上的未知 JSON 自动可信。

ts
import type { SessionEntry } from "@earendil-works/pi-coding-agent"
import { allowedFiles, type Report } from "./inspect.js"

export const entryType = "lesson.report.v1"

export function isReport(value: unknown): value is Report {
  if (typeof value !== "object" || value === null) {
    return false
  }
  const report = value as Record<string, unknown>
  return (
    allowedFiles.some((file) => file === report.file) &&
    typeof report.bytes === "number" &&
    Number.isSafeInteger(report.bytes) &&
    report.bytes >= 0 &&
    Array.isArray(report.names) &&
    report.names.every((name) => typeof name === "string")
  )
}

export function restoreReport(branch: SessionEntry[]): Report | undefined {
  let report: Report | undefined
  for (const entry of branch) {
    if (entry.type === "custom" && entry.customType === entryType && isReport(entry.data)) {
      report = entry.data
    }
  }
  return report
}

lesson.report.v1 标识这一版数据协议。字段含义变化时,需要新的版本或明确迁移;不要只修改接口类型,期待旧会话记录自己适配。验证失败的条目在本例中被跳过,生产实现可以记录诊断,但不能把损坏内容直接送进模型。

让检查结果跟随分支

ts
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
import type { Report } from "../src/inspect.js"
import { registerInspection } from "../src/register-inspection.js"
import { entryType, restoreReport } from "../src/state.js"

export default function (pi: ExtensionAPI) {
  let last: Report | undefined
  const refresh = (ctx: ExtensionContext) => {
    last = restoreReport(ctx.sessionManager.getBranch())
  }
  registerInspection(pi, {
    save(report) {
      pi.appendEntry(entryType, report)
      last = report
    },
  })
  pi.on("session_start", (_event, ctx) => refresh(ctx))
  pi.on("session_tree", (_event, ctx) => refresh(ctx))
  pi.registerCommand("lesson-last", {
    description: "读取当前会话分支最近一次教学检查",
    handler: async (_args, ctx) => {
      refresh(ctx)
      if (ctx.hasUI) {
        ctx.ui.notify(last ? JSON.stringify(last) : "当前分支尚无检查结果", "info")
      }
    },
  })
}

appendEntry 保存自定义数据,不把它自动注入模型上下文。工具已经通过返回值把报告交给模型,额外保存的目的只是让扩展恢复状态。

getBranch() 只读取当前分支。getEntries() 包括其他分支的条目,如果从完整历史取最后一个报告,可能读到已经放弃的分支。恢复规则应是“当前分支上最后一个合法的报告”,而非“文件中时间最近的报告”。

切换后重新读取,避免使用旧上下文

在 session_start 和 session_tree 时恢复,手动查询时也再次恢复。这样 /lesson-last 不依赖某个事件必然已刷新缓存。普通事件上下文与命令上下文能做的事情不同;切换、新建会话等控制动作应使用宿主允许的命令上下文 API,并遵守等待空闲等前置条件。

不要在会话生命周期回调里再次无条件切换会话,也不要把旧 ctx 存进定时器继续调用。切换可能使旧任务的目录、分支和 UI 都失效;异步任务应绑定会话身份与取消信号,并在回写前检查仍属于原会话。

用分支实验验证恢复

bash
npx pi --extension ./extensions/state.ts

先检查 fixtures/sample.ts,再在当前宿主支持的会话树界面建立分支,检查 fixtures/other.ts。切回原分支,执行 /lesson-last 应得到原分支的报告。没有任何报告的新会话应显示“尚无检查结果”,而不是复用上个会话的值。

也可以用真实 SessionManager 构造分支,单独测试 restoreReport。这验证了恢复算法和条目格式,真实交互切换是否派发预期事件仍要另测。

会话记录不是所有状态的归宿

报告较小、与任务历史绑定,适合保存在会话里。凭据、大文件缓存、跨用户共享数据和事务任务不适合都塞进自定义条目。它们需要各自的存储、访问控制和清理规则;会话压缩或分支操作也不能替代数据库事务。

这套做法恢复的是扩展报告,并不自动实现外部副作用的幂等。写文件或发请求后如果进程崩溃,仍需通过任务 ID、操作日志或服务端幂等键判断是否已经执行。

依据与版本

代码以已发布的 1.5.0 包导出、类型声明和加载器为准。在线主分支会继续变化,遇到接口差异时先核对版本。