AgentSessionRuntime 分析

1. 一句话结论

AgentSessionRuntime 是"当前 AgentSession 的宿主"。它本身不执行对话,只负责在 /new、/resume、/fork、/import 这类会话替换操作中,按固定顺序完成:

  1. 取消检查;
  2. 结算并销毁旧会话;
  3. 通过工厂重建 cwd 绑定的服务和新 AgentSession;
  4. 替换内部引用;
  5. 让宿主重新绑定订阅和扩展;
  6. 在 withSession 回调中把新会话上下文交给调用方。

核心设计约束是:AgentSession 绑定一个 cwd 和一套服务,不能原地改造成另一个 cwd 的会话。因此跨 cwd 替换必须"销毁旧的、重建新的",而不是修改旧对象。

2. 它解决什么问题

AgentSession 持有单个会话的模型、工具、队列、压缩状态和扩展运行时,同时依赖一组 cwd 绑定的服务:

  • SettingsManager:项目设置随 cwd 变化;
  • DefaultResourceLoader:从 cwd 发现 skills、prompt templates、themes、context files、extensions;
  • ModelRuntime:扩展可以在加载时注册 provider、native provider、virtual model;
  • SessionManager:会话文件目录和 cwd 也随目标会话变化。

如果把这些替换逻辑继续放在 AgentSession 内部,会出现两个问题:

  1. 一个已经绑定 cwd A 的会话对象要变成 cwd B 的会话,必须替换自己依赖的大部分字段,状态边界不清晰;
  2. 启动路径和后续替换路径容易分叉,出现"启动时这样初始化,切换时那样初始化"的不一致。

当前方案把替换逻辑上移到一个薄宿主,并让启动和后续替换共用同一个工厂:

ts 复制代码
type CreateAgentSessionRuntimeFactory = (options: {
  cwd: string;
  agentDir: string;
  sessionManager: SessionManager;
  sessionStartEvent?: SessionStartEvent;
  projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;

工厂闭包持有进程级固定输入(CLI 参数、认证存储、扩展工厂等),每次调用时针对目标 cwd 重新创建 cwd 绑定服务。

一个具体链路:用户恢复一个保存在其他目录的会话。

  1. switchSession(path) 打开 JSONL,读出 header 中的 cwd;
  2. assertSessionCwdExists 检查该目录是否仍存在;
  3. 销毁当前会话;
  4. 工厂用目标 cwd 重新创建设置、资源加载器、模型运行时;
  5. 新 AgentSession 从目标会话的历史中恢复模型和 thinking level;
  6. 宿主重新绑定事件订阅和扩展。

3. 模块结构

复制代码
┌──────────────────────────────────────────────────────────────┐
│ 宿主层                                                        │
│ InteractiveMode / RpcMode / PrintMode / SDK 调用方            │
│  - setRebindSession()                                        │
│  - setBeforeSessionInvalidate()                              │
│  - 调用 newSession/switchSession/fork/importFromJsonl        │
└───────────────────────────┬──────────────────────────────────┘
                            │
┌───────────────────────────▼──────────────────────────────────┐
│ AgentSessionRuntime                                          │
│  - _session: AgentSession        (可替换)                   │
│  - _services: AgentSessionServices(可替换)                  │
│  - createRuntime: Factory        (固定)                     │
│  - rebindSession / beforeSessionInvalidate(宿主回调)        │
└───────────────────────────┬──────────────────────────────────┘
                            │ createRuntime(...)
┌───────────────────────────▼──────────────────────────────────┐
│ CreateAgentSessionRuntimeFactory(由宿主提供)                 │
│  createAgentSessionServices({ cwd, agentDir, ... })          │
│  createAgentSessionFromServices({ services, ... })           │
└───────────────────────────┬──────────────────────────────────┘
                            │
┌───────────────────────────▼──────────────────────────────────┐
│ AgentSessionServices                                         │
│  cwd / agentDir / modelRuntime / settingsManager /           │
│  resourceLoader / diagnostics                                │
└──────────────────────────────────────────────────────────────┘

4. 类型与公开契约

4.1 CreateAgentSessionRuntimeResult

ts 复制代码
interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult {
  services: AgentSessionServices;
  diagnostics: AgentSessionRuntimeDiagnostic[];
}

CreateAgentSessionResult 提供:

  • session:新建的 AgentSession;
  • extensionsResult:扩展加载结果;
  • modelFallbackMessage:模型恢复失败时的提示。

运行时额外保存 services 和 diagnostics,但不保存 extensionsResult 。需要扩展结果时通过 services.resourceLoader.getExtensions() 获取。

4.2 工厂职责

CreateAgentSessionRuntimeFactory 的注释明确写了四步:

  1. 闭包持有进程级固定输入;
  2. 为目标 cwd 重建 cwd 绑定服务;
  3. 针对这些服务解析模型、thinking level、工具等会话选项;
  4. 创建 AgentSession。

工厂必须返回 services 和 diagnostics,因为运行时后续的 cwd getter、诊断信息和下一次替换都依赖它们。

4.3 AgentSessionRuntime 公开面

成员 类型 作用
session getter 当前活动会话
services getter 当前 cwd 绑定服务
cwd getter 返回 services.cwd
diagnostics getter 最近一次创建返回的诊断
modelFallbackMessage getter 最近一次创建的模型回退提示
setRebindSession(fn?) 方法 替换成功后重新绑定宿主的回调
setBeforeSessionInvalidate(fn?) 方法 旧会话失效前的同步 UI 清理
newSession(options?) 方法 新建空会话
switchSession(path, options?) 方法 恢复指定会话
fork(entryId, options?) 方法 从某个 entry 分叉
importFromJsonl(path, cwdOverride?) 方法 导入外部 JSONL
dispose() 方法 退出时销毁当前会话

diagnostics 和 modelFallbackMessage 在每次 apply() 时被整体替换,不会累积。

4.4 AgentSessionServices

ts 复制代码
interface AgentSessionServices {
  cwd: string;
  agentDir: string;
  modelRuntime: ModelRuntime;
  settingsManager: SettingsManager;
  resourceLoader: ResourceLoader;
  diagnostics: AgentSessionRuntimeDiagnostic[];
}

createAgentSessionServices() 的行为:

  1. resolvePath(cwd),保证 cwd 是规范路径;
  2. agentDir 缺省时使用 getAgentDir();
  3. 未注入 modelRuntime 时创建新的 ModelRuntime,读取 agentDir/auth.json 和 agentDir/models.json;
  4. 未注入 settingsManager 时调用 SettingsManager.create(cwd, agentDir);
  5. 创建 DefaultResourceLoader 并 reload();
  6. 把扩展注册的 provider、native provider、virtual model 应用到 ModelRuntime,失败转为诊断;
  7. modelRuntime.refresh({ allowNetwork: false });
  8. 应用扩展 flag 值,未知 flag 或缺失值转为错误诊断。

注意第 7 步:替换会话时不会主动做网络模型目录刷新,避免切换路径引入网络等待。

4.5 诊断模型

ts 复制代码
interface AgentSessionRuntimeDiagnostic {
  type: "info" | "warning" | "error";
  message: string;
}

诊断来源包括:

  • 扩展 provider 注册失败;
  • 扩展 flag 未知或缺少值;
  • 工厂自身追加的项目信任、设置解析、扩展加载错误;
  • 会话选项解析错误。

运行时不打印、不退出,由宿主决定如何处理。CLI 只在启动时消费 runtime.diagnostics;后续替换产生的诊断虽然保存在 runtime.diagnostics 中,但当前 CLI 不会自动展示。

5. 启动创建流程

5.1 createAgentSessionRuntime

ts 复制代码
export async function createAgentSessionRuntime(createRuntime, options) {
  assertSessionCwdExists(options.sessionManager, options.cwd);
  const result = await createRuntime(options);
  return new AgentSessionRuntime(
    result.session,
    result.services,
    createRuntime,
    result.diagnostics,
    result.modelFallbackMessage,
  );
}

三个要点:

  1. 初始会话也要检查 cwd 是否存在,避免启动后才发现会话目录失效;
  2. 工厂接收的 options 原样传入,初始调用不包含 projectTrustContext;
  3. 同一个工厂被保存在运行时上,后续所有替换复用,保证启动和替换走同一条创建路径。

构造函数是公开的,测试里也会直接 new AgentSessionRuntime(...)。直接构造会跳过初始 cwd 检查。

5.2 CLI 中的工厂实现

main.ts:733-864 是工厂的完整实现,可以分成六段:

  1. 项目信任

    • 以 cwd 为 key 缓存信任结果;
    • 如果该 cwd 有需要信任的项目资源且尚未解析,则在 resourceLoaderReloadOptions.resolveProjectTrust 中交互或非交互解析;
    • 把结果写入 projectTrustByCwd。
  2. 设置

    • SettingsManager.create(cwd, agentDir, { projectTrusted });
    • 每次替换都创建新的 settings manager,使项目设置随 cwd 变化。
  3. 服务

    • createAgentSessionServices({ cwd, agentDir, settingsManager, ... });
    • 传入固定的 CLI 资源路径(extension/skill/prompt/theme)和扩展工厂;
    • 传入 15 秒的模型运行时初始化超时。
  4. 诊断汇总

    • 项目信任诊断;
    • 服务诊断;
    • 设置诊断;
    • 扩展加载错误和警告。
  5. 模型与会话选项

    • 从 CLI 参数或设置解析模型范围;
    • buildSessionOptions 解析模型、thinking level、工具等;
    • --api-key 在模型确定后写入运行时。
  6. 创建会话

    • createAgentSessionFromServices(...);
    • 如果 CLI 显式指定了 thinking level,再调用一次 setThinkingLevel 以覆盖会话恢复值;
    • 返回 { ...created, services, diagnostics }。

这个实现说明了工厂为什么必须由宿主提供:CLI 参数、项目信任策略、扩展路径、认证存储都是进程级状态,运行时不应当知道它们。

5.3 会话创建时的恢复行为

createAgentSession() 会根据 SessionManager 中已有的历史恢复:

  • 模型:从分支中的 model_change 或最后一条 assistant 消息恢复;
  • thinking level:从 thinking_level_change 恢复,否则使用设置默认值;
  • 如果没有可用模型,返回 modelFallbackMessage。

因此 switchSession() 不需要额外复制模型状态,目标会话自己的历史就是权威来源。测试 restores model and thinking state from the destination session 覆盖了这一点。

6. 替换流程的四个阶段

所有替换操作都遵循同一骨架,差异只在"如何准备下一个 SessionManager"和"事件 reason"。

6.1 阶段一:取消检查

newSession / switchSession / importFromJsonl 调用:

ts 复制代码
emitBeforeSwitch(reason, targetSessionFile)

fork 调用:

ts 复制代码
emitBeforeFork(entryId, { position })

只有当前扩展运行时存在对应 handler 时才 emit。返回 { cancel: true } 时立即返回,不产生任何副作用。

取消检查位于所有文件操作和 teardown 之前。对 fork 来说,取消甚至优先于 entry 校验:测试中 fork("missing-entry", { position: "at" }) 在取消时返回 { cancelled: true },而不是抛错。

6.2 阶段二:结算并销毁旧会话

ts 复制代码
private async teardownCurrent(reason, targetSessionFile?) {
  await this.session.abort();
  await emitSessionShutdownEvent(this.session.extensionRunner, {
    type: "session_shutdown",
    reason,
    targetSessionFile,
  });
  this.beforeSessionInvalidate?.();
  this.session.dispose();
}

顺序不能交换:

  1. abort() 必须在 session_shutdown 之前

    • abort() 会中止当前 agent run、重试、压缩、分支摘要,并等待会话 idle;
    • 被中止的 turn 会写入 tool result,避免旧会话留下悬空的 tool call;
    • 测试 settles the active response before session replacement 明确验证了这一点:替换前阻塞中的工具会得到持久化的 tool result。
  2. session_shutdown 必须在 dispose() 之前

    • 扩展需要在上下文仍然有效时释放资源;
    • dispose() 之后 ExtensionRunner 会被 invalidate,旧 ctx 再访问会抛错。
  3. beforeSessionInvalidate 是同步回调

    • 它在 shutdown handler 完成后、dispose() 之前执行;
    • 交互模式用它 resetExtensionUI(),因为 TUI 组件的卸载不能等待事件循环;
    • 此时旧扩展上下文还没有失效,仍可读取旧会话信息。
  4. dispose() 负责最终失效

    • 中止重试、压缩、分支摘要、bash;
    • invalidate 扩展运行时;
    • 断开 agent 事件;
    • 清空事件监听;
    • 取消 cache warmer;
    • 清理会话资源。

6.3 阶段三:创建并应用新运行时

ts 复制代码
this.apply(await this.createRuntime({ ... }));

apply() 只做四件事:

ts 复制代码
this._session = result.session;
this._services = result.services;
this._diagnostics = result.diagnostics;
this._modelFallbackMessage = result.modelFallbackMessage;

关键失败语义:没有回滚 。如果 createRuntime 抛错,旧会话已经被 teardown,运行时字段仍指向已销毁的旧会话,错误直接抛给调用方。注释也写明"If creation fails, the error is propagated to the caller. The caller is responsible for user-facing error handling."

因此宿主通常把这类错误当作 fatal error 处理。交互模式的 handleFatalRuntimeError 会记录崩溃并退出。

6.4 阶段四:重新绑定与 withSession

ts 复制代码
private async finishSessionReplacement(withSession?) {
  if (this.rebindSession) {
    await this.rebindSession(this.session);
  }
  if (withSession) {
    await withSession(this.session.createReplacedSessionContext());
  }
}

顺序是固定的:

  1. 先 rebindSession,宿主把订阅、扩展绑定、UI 绑定切到新会话;
  2. 再执行 withSession,让调用方用新会话上下文继续工作。

测试 regression #2860 验证了事件顺序是:

复制代码
start:1 → shutdown:1 → start:2 → with:1

其中 start:2 来自 rebind 过程中的 bindExtensions(),with:1 来自 withSession。

createReplacedSessionContext() 从新会话的扩展运行时创建命令上下文,并覆盖:

  • sendMessage:发送自定义消息到新会话;
  • sendUserMessage:发送用户消息到新会话。

旧 ctx 和旧 pi 对象在 dispose() 后都会抛"stale after session replacement"错误。

6.5 完整时序

以 newSession() 为例:

复制代码
调用方
  |
  | newSession()
  v
AgentSessionRuntime
  |
  | 1. session_before_switch { reason: "new" }
  |    扩展可返回 { cancel: true } ──> 直接返回 { cancelled: true }
  |
  | 2. 记录 previousSessionFile
  | 3. 创建下一个 SessionManager
  |
  | 4. session.abort()
  |    等待当前 turn 结算
  |
  | 5. session_shutdown { reason: "new", targetSessionFile }
  |
  | 6. beforeSessionInvalidate()
  |
  | 7. session.dispose()
  |
  | 8. createRuntime({ cwd, agentDir, sessionManager, sessionStartEvent })
  |        |
  |        +--> 重建 services
  |        +--> 创建新 AgentSession
  |
  | 9. apply(result)
  |
  | 10. rebindSession(newSession)
  |        |
  |        +--> host.bindExtensions() 触发 session_start
  |
  | 11. withSession(newSession.createReplacedSessionContext())
  |
  v
{ cancelled: false }

7. 四个替换操作

7.1 newSession(options?)

参数:

ts 复制代码
{
  parentSession?: string;
  setup?: (sessionManager: SessionManager) => Promise<void>;
  withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
}

行为:

  1. emit session_before_switch { reason: "new" },此时 targetSessionFile 为 undefined;
  2. 记录旧会话文件;
  3. 从旧 SessionManager 取 session 目录;
  4. 旧会话持久化时创建新的持久化 manager,否则创建 SessionManager.inMemory(this.cwd);
  5. 如果传入 parentSession,写入新会话 header;
  6. teardown 旧会话,session_shutdown.reason = "new";
  7. 工厂以当前 cwd 创建新运行时;
  8. 如果传入 setup,在新会话的 SessionManager 上执行,然后 session.refreshContext() 重建 finalized transcript;
  9. rebind;
  10. 执行 withSession。

要点:

  • newSession 不改变 cwd ,始终使用当前 cwd;
  • session 目录沿用旧 manager 的目录,而不是重新计算默认目录;
  • 内存会话继续保持内存模式;
  • setup 在 rebind 之前执行,因此 session_start handler 能看到 setup 写入的 entry;
  • refreshContext() 是必要的,因为 setup 可能直接向 SessionManager 追加 entry,而 AgentSession 的公开 transcript 需要重新投影。

7.2 switchSession(sessionPath, options?)

参数:

ts 复制代码
{
  cwdOverride?: string;
  withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
  projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
}

行为:

  1. emit session_before_switch { reason: "resume", targetSessionFile: sessionPath };
  2. 记录旧会话文件;
  3. SessionManager.open(sessionPath, undefined, cwdOverride);
  4. assertSessionCwdExists(sessionManager, this.cwd);
  5. teardown,session_shutdown.reason = "resume",target 为解析后的会话文件;
  6. 工厂以 sessionManager.getCwd() 创建运行时,session_start.reason = "resume",并携带 previousSessionFile;
  7. 如果提供了 projectTrustContextFactory,用目标 cwd 生成 projectTrustContext 传给工厂;
  8. rebind;
  9. 执行 withSession。

要点:

  • SessionManager.open 未传 sessionDir,因此后续 /new 会把新会话建在目标会话文件所在目录;
  • cwd 来源优先级:cwdOverride > 会话 header 中的 cwd > process.cwd();
  • cwd 不存在时抛 MissingSessionCwdError,交互模式捕获后询问用户是否改用当前 cwd,再以 cwdOverride 重试;
  • session_before_switch 的 targetSessionFile 是调用方传入的原始路径,而 session_shutdown 的 target 是解析后的绝对路径;传相对路径时两者可能不同;
  • 打开和 cwd 检查都在 teardown 之前,失败时旧会话不受影响。

7.3 fork(entryId, options?)

参数:

ts 复制代码
{
  position?: "before" | "at";   // 默认 "before"
  withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
}

返回:

ts 复制代码
{ cancelled: boolean; selectedText?: string }

行为:

  1. emit session_before_fork { entryId, position };
  2. 取消则直接返回,不校验 entry;
  3. 读取 entry:
    • position: "at":任意 entry 都合法,目标 leaf 是 entry.id;
    • position: "before":entry 必须是 user message,目标 leaf 是 entry.parentId,同时提取用户文本作为 selectedText;
  4. 记录旧会话文件;
  5. 根据是否持久化走不同路径。

持久化路径:

  • targetLeafId 为 null(在第一条 user message 之前分叉):
    • 在当前 session 目录新建 manager;
    • newSession({ parentSession: currentSessionFile });
    • teardown 后创建新运行时。
  • targetLeafId 非空:
    • 要求当前会话文件已经落盘,否则抛 "This session has not been saved yet...";
    • SessionManager.open(currentSessionFile, sessionDir) 打开一个新 manager;
    • createBranchedSession(targetLeafId) 生成只包含根到目标 leaf 的新文件;
    • teardown 后创建新运行时。

内存路径:

  • 直接复用当前 SessionManager;
  • 先 teardown;
  • 再执行 newSession({ parentSession }) 或 createBranchedSession(targetLeafId);
  • 用同一个 manager 创建新运行时。

要点:

  • 持久化 fork 是在"新打开的 manager"上创建分支,不会修改活动会话的 manager;
  • 内存 fork 是在 teardown 之后修改活动 manager,因此顺序不能反;
  • position: "before" 只接受 user message,因为它的语义是"回到这条用户输入之前重新开始";
  • position: "at" 可以复制当前活动分支,用于 clone;
  • selectedText 只对 before 有意义,用于把原用户输入放回编辑器;
  • 测试覆盖了持久化和内存两种 at 分叉、无效 entry、未落盘会话、取消优先等场景。

7.4 importFromJsonl(inputPath, cwdOverride?)

行为:

  1. resolvePath(inputPath);
  2. 文件不存在则抛 SessionImportFileNotFoundError;
  3. 取当前 session 目录,不存在则递归创建;
  4. 目标路径默认为 sessionDir/basename(inputPath);
  5. 如果目标路径就是源文件,标记 sourceAlreadyStored,不复制;
  6. 否则在文件名冲突时追加 -1、-2 等后缀;
  7. emit session_before_switch { reason: "resume", targetSessionFile: destinationPath };
  8. 取消则返回,不复制;
  9. 非原地导入时用 COPYFILE_EXCL 复制,避免覆盖已有文件;
  10. SessionManager.open(destinationPath, sessionDir, cwdOverride);
  11. assertSessionCwdExists(sessionManager, this.cwd);
  12. teardown,session_shutdown.reason = "resume";
  13. 工厂以目标 cwd 创建运行时,session_start.reason = "resume";
  14. rebind,没有 withSession。

要点:

  • 导入被建模为一次 resume,因此事件 reason 是 resume;
  • 冲突处理保证不会覆盖 session 目录中已有的同名文件;
  • 导入会复制文件,源文件保留;
  • 相对路径通过 resolvePath 以 process.cwd() 为基准解析,而不是 runtime.cwd;切换到其他 cwd 后,相对导入路径仍相对进程启动目录;
  • cwdOverride 用于会话 header 中的 cwd 已不存在的情况;
  • importFromJsonl 不接收 projectTrustContextFactory,目标 cwd 的信任解析由工厂内部按非交互方式处理。

7.5 四个操作对比

维度 newSession switchSession fork importFromJsonl
前置事件 session_before_switch session_before_switch session_before_fork session_before_switch
事件 reason new resume fork resume
是否可取消 是 是 是 是
cwd 变化 否 可能 否 可能
是否重建 services 是 是 是 是
是否新建会话文件 持久化时是 打开已有文件 持久化时是 复制并打开
是否支持 withSession 是 是 是 否
是否支持 parentSession 是 否 自动写入 保留原 header
是否支持 setup 是 否 否 否
内存会话支持 是 是 是 见 11.4

8. 事件时序与扩展契约

8.1 事件顺序

操作 事件序列
启动 session_start { reason: "startup" }
newSession session_before_switch { reason: "new", targetSessionFile: undefined } → session_shutdown { reason: "new", targetSessionFile: 新文件 } → session_start { reason: "new", previousSessionFile: 旧文件 }
switchSession session_before_switch { reason: "resume", targetSessionFile: 目标路径 } → session_shutdown { reason: "resume", targetSessionFile: 解析后的目标路径 } → session_start { reason: "resume", previousSessionFile: 旧文件 }
fork session_before_fork { entryId, position } → session_shutdown { reason: "fork", targetSessionFile: 新文件 } → session_start { reason: "fork", previousSessionFile: 旧文件 }
importFromJsonl session_before_switch { reason: "resume", targetSessionFile: 目标路径 } → session_shutdown { reason: "resume", targetSessionFile: 目标路径 } → session_start { reason: "resume", previousSessionFile: 旧文件 }
dispose session_shutdown { reason: "quit" }

注意:session_start 不是运行时发出的,而是宿主在 rebind 时调用 session.bindExtensions() 触发的。AgentSession.bindExtensions() 会 emit 创建时保存的 _sessionStartEvent。

因此宿主契约是:替换成功后必须重新绑定扩展 ,否则新会话不会收到 session_start,扩展命令和 UI 也不会挂到新会话上。

8.2 取消语义

  • session_before_switch 返回 { cancel: true }:不打开、不复制、不 teardown、不创建;
  • session_before_fork 返回 { cancel: true }:不校验 entry、不创建分支;
  • 只有存在 handler 时才 emit,没有 handler 时直接视为未取消;
  • handler 抛错会向上传播,此时尚未 teardown,旧会话仍有效。

8.3 session_shutdown 的 targetSessionFile

  • newSession:新 manager 的 getSessionFile()。注意文件可能尚未真正落盘,SessionManager 只在出现 user/assistant 消息时才创建文件;
  • switchSession:目标会话文件的解析路径;
  • fork:新分支文件路径,或新建会话的路径;
  • importFromJsonl:目标副本路径;
  • dispose:不传。

扩展可以用这个字段判断"我要切换到哪个文件",但不能假设文件已经存在。

8.4 旧上下文失效

AgentSession.dispose() 调用 extensionRunner.invalidate(...)。之后:

  • 旧 pi 对象的方法会抛错;
  • 旧命令 ctx 会抛错;
  • 错误信息明确要求把替换后的工作放进 withSession,并使用传入的新 ctx。

这是防止扩展在会话替换后继续操作旧会话的关键保护。

8.5 withSession 的用途

withSession 接收一个绑定到新会话的 ReplacedSessionContext,适合做:

  • 向新会话发送初始用户消息;
  • 发送自定义消息;
  • 读取新会话的 sessionManager;
  • 执行依赖新 cwd 的命令级操作。

不适合:

  • 继续使用旧 ctx 捕获的引用;
  • 在 withSession 里再次触发替换(容易造成重入)。

9. cwd 与服务重建

9.1 哪些状态随 cwd 重建

状态 是否重建 原因
cwd 是 目标会话的 header 或 override
SettingsManager 是 项目设置按 cwd 合并
DefaultResourceLoader 是 skills、prompts、themes、context files、extensions 按 cwd 发现
ModelRuntime 是(未注入时) 扩展可在新 cwd 注册 provider/model
agentDir 否 全局目录,跨 cwd 固定
SessionManager 由操作决定 new/fork/import 可能新建或复用

CLI 的工厂每次都会创建新的 SettingsManager 和 ModelRuntime,因此切换会话会重新读取认证和模型配置。这是"正确性优先"的选择:新 cwd 的扩展可能注册不同 provider。代价是每次切换都有一次服务初始化成本。

如果宿主希望复用 ModelRuntime(例如测试中避免重复认证),可以通过 createAgentSessionServices({ modelRuntime }) 注入。但要注意:复用的运行时不会自动清除上一个 cwd 扩展注册的 provider。

9.2 cwd 不存在的处理

assertSessionCwdExists 的规则:

  • 没有会话文件:不检查;
  • 会话 cwd 为空或存在:通过;
  • 否则抛 MissingSessionCwdError,包含 sessionFile、sessionCwd、fallbackCwd。

交互模式捕获后:

  1. 显示提示,询问是否在当前 cwd 继续;
  2. 用户确认后,以 cwdOverride 重新调用 switchSession 或 importFromJsonl;
  3. 用户取消则保持原会话。

非交互模式下直接报错并退出。

9.3 项目信任

switchSession 支持 projectTrustContextFactory,因为交互模式需要在恢复时询问用户是否信任目标项目。newSession 和 fork 不改变 cwd,所以不需要。importFromJsonl 可能改变 cwd,但当前接口不提供 factory,由工厂内部按非交互方式解析。

CLI 用 projectTrustByCwd 缓存每个 cwd 的信任结果,避免重复询问。

10. 宿主集成模式

10.1 交互模式

InteractiveMode 在构造函数中注册两个回调:

ts 复制代码
runtimeHost.setBeforeSessionInvalidate(() => {
  this.resetExtensionUI();
});
runtimeHost.setRebindSession(async () => {
  await this.rebindCurrentSession({ renderBeforeBind: true });
  this.themeController.applyFromSettings();
});

rebindCurrentSession 做:

  1. 取消旧订阅;
  2. 重置 program status;
  3. 应用运行时设置;
  4. 渲染会话状态并订阅新 agent(renderBeforeBind 时先渲染再绑定);
  5. bindCurrentSessionExtensions(),其中调用 session.bindExtensions() 并挂上命令上下文动作;
  6. 如果绑定过程中会话又被替换,直接返回,避免把旧绑定挂到新会话上;
  7. 更新 provider 数量、编辑器边框、终端标题。

命令上下文动作把 newSession、fork、switchSession 转发给运行时;navigateTree 和 reload 仍直接调用当前会话。

10.2 RPC 模式

RPC 模式同样设置 setRebindSession,并在 rebind 中:

  • 更新本地 session 变量;
  • 重新 bindExtensions,挂上 RPC 的 UI context 和命令动作;
  • 取消旧订阅,订阅新会话事件。

需要注意一个代码层面的观察:RPC 的 switch_session、fork、clone 命令处理器在调用 runtimeHost.switchSession/fork 之后又显式 await rebindSession()。而运行时在 finishSessionReplacement 中已经调用过注册的 rebind 回调,因此这条路径会执行两次 bindExtensions(),第二次会再次 emit session_start。这看起来是历史遗留的重复调用,本次分析未运行 RPC 端到端测试验证其可观察影响。

10.3 Print 模式

Print 模式也注册 rebind 回调,重新绑定扩展并重建订阅。它通常只运行一次,但在扩展命令触发替换时仍能正确切换。

10.4 SDK 示例

examples/sdk/13-session-runtime.ts 没有使用 setRebindSession,而是手动维护:

ts 复制代码
async function bindSession() {
  unsubscribe?.();
  const session = runtime.session;
  await session.bindExtensions({});
  unsubscribe = session.subscribe(...);
  return session;
}

let session = await bindSession();
await runtime.newSession();
session = await bindSession();

这展示了最小契约:

  • 替换后从 runtime.session 重新取会话;
  • 取消旧订阅;
  • 重新 bindExtensions;
  • 重新订阅。

示例中工厂调用 createAgentSessionServices({ cwd }) 时忽略了工厂参数里的 agentDir。生产代码应传入 agentDir,否则自定义 agent 目录会被忽略。

10.5 宿主接入检查清单

  1. 工厂必须重建 cwd 绑定服务,并返回 services 和 diagnostics;
  2. 工厂应使用参数中的 cwd 和 agentDir,不要读取全局 cwd;
  3. 替换后必须重新绑定扩展和事件订阅;
  4. 旧订阅必须取消,否则会同时收到旧会话事件;
  5. 旧 ctx 和旧 pi 不能在替换后继续使用;
  6. 替换后应读取 runtime.diagnostics,否则新 cwd 的配置错误会被静默忽略;
  7. 捕获替换异常并按 fatal error 处理,因为旧会话已经被销毁。

11. 边界条件、失败语义与潜在问题

11.1 没有回滚

teardown 之后如果工厂抛错,运行时处于"旧会话已销毁、新会话未建立"的状态。调用方只能报错退出或自行恢复。这是有意的简化:回滚需要保留旧服务并重新绑定,复杂度和出错面都更高。

11.2 没有并发保护

运行时没有内部锁或状态机。两个并发的 newSession() / switchSession() 调用可以在 await 点交错,导致重复 teardown 或 apply 顺序不确定。文档也明确说这些操作是 command-only,不应从生命周期 handler 中调用,否则可能死锁。

11.3 dispose() 不等待当前 turn

teardownCurrent 会先 await session.abort(),但 dispose() 只 emit shutdown 后直接 session.dispose()。AgentSession.dispose() 内部同步 abort,但不等待 idle。因此退出路径上未完成的 turn 不保证像替换路径那样完成持久化。宿主若需要完整结算,应先调用 session.abort()。

11.4 内存会话的导入

importFromJsonl 依赖 sessionManager.getSessionDir()。SessionManager.inMemory() 的 sessionDir 是空字符串,代码随后会执行 mkdirSync(""),在 Node 上会抛 ENOENT。因此内存会话上的导入路径存在边界问题。持久化会话不受影响。本次分析未运行该场景验证。

11.5 相对导入路径

resolvePath(inputPath) 默认以 process.cwd() 为基准。切换到一个不同的会话 cwd 后,/import relative.jsonl 仍然相对进程启动目录,而不是 runtime.cwd。如果宿主希望相对当前会话 cwd 解析,需要在调用前自行拼成绝对路径。

11.6 诊断只保留最近一次

apply() 整体替换 _diagnostics。如果宿主需要历史诊断,必须在每次替换后立即读取并保存。当前 CLI 只在启动时展示诊断。

11.7 extensionsResult 不保留

CreateAgentSessionRuntimeResult 继承了 extensionsResult,但 AgentSessionRuntime 没有保存它。需要时通过 services.resourceLoader.getExtensions() 获取。两者在正常情况下应一致,因为 createAgentSessionFromServices 把同一个 resourceLoader 传给了 createAgentSession。

11.8 工厂参数中的 agentDir

工厂签名提供 agentDir,但运行时只负责传递,不验证工厂是否使用。示例代码忽略了它;如果宿主也忽略,自定义 agent 目录在替换后会退回默认目录,导致认证、设置或模型配置不一致。

11.9 事件 target 的路径形态

session_before_switch 使用调用方传入的原始路径,session_shutdown 使用 SessionManager 解析后的路径。扩展若比较这两个值,应先规范化。

11.10 fork 的校验时机

session_before_fork 在 entry 校验之前 emit。取消优先于校验,这对扩展是友好的(扩展可以在不关心 entry 是否有效的情况下先拦截),但也意味着取消路径不会暴露无效 entry 错误。

12. 测试覆盖

测试文件 覆盖内容
test/suite/agent-session-runtime.test.ts 替换前结算活动 turn、导入同名文件不覆盖、new/resume/fork 事件顺序、取消、未落盘会话 fork 报错、持久化与内存 fork、跨 cwd 替换、模型与 thinking 恢复
test/agent-session-runtime-events.test.ts 事件顺序、beforeSessionInvalidate 与 rebindSession 的相对顺序、旧 ctx 失效、fork 取消
test/suite/regressions/2860-replaced-session-context.test.ts withSession 顺序、替换后 ctx 指向新会话、旧 ctx/pi 失效、new/fork/switch 三种 withSession
test/suite/regressions/2753-reload-stale-resource-settings.test.ts reload 后资源设置不陈旧
test/suite/regressions/8724-in-memory-fork-active-tool.test.ts 内存会话 fork 时活动工具结算
test/suite/regressions/9068-user-bash-fail-closed.test.ts 用户 bash 失败关闭
test/agent-session-branching.test.ts 分支、fork、切换相关行为
test/session-cwd.test.ts 初始 cwd 断言与缺失 cwd 处理

测试中最值得注意的两个断言:

  1. 替换前必须结算 turn :旧会话中阻塞的工具在替换后仍留下持久化的 toolResult;
  2. rebind 先于 withSession :事件顺序为 shutdown → start → with,保证 withSession 看到的是已完成绑定的新会话。

13. 设计评价

优点

  1. 单一创建路径

    启动、/new、/resume、/fork、/import 都走同一个工厂,避免初始化逻辑分叉。

  2. 清晰的职责边界

    运行时只管理"当前会话 + 服务"的替换,不关心 CLI 参数、认证、项目信任;这些留在工厂闭包里。

  3. 销毁顺序经过设计

    abort → shutdown → beforeSessionInvalidate → dispose 覆盖了"结算数据、通知扩展、同步清理 UI、失效上下文"四个需求,顺序有测试保护。

  4. 显式的宿主契约

    setRebindSession 和 withSession 把"替换后重新绑定"和"替换后继续工作"分开,避免扩展在旧上下文上操作。

  5. 可取消

    替换前的事件允许扩展拦截,且取消发生在所有副作用之前。

代价与风险

  1. 每次替换重建整套服务

    对 CLI 是正确性优先;对频繁切换的宿主可能有明显成本,尤其是认证读取和资源发现。

  2. 没有回滚

    工厂失败后只能退出或由宿主自行恢复。这是运行时最小的错误模型,但要求宿主把替换错误视为 fatal。

  3. 宿主契约靠约定而非类型强制

    setRebindSession 是可选的。忘记设置且不手动绑定,会导致新会话没有 session_start、没有扩展命令、没有事件订阅。SDK 示例用注释提醒这一点。

  4. 并发和重入没有保护

    文档用"command-only"约束规避,而不是运行时强制。

  5. 诊断在替换后容易被忽略

    运行时保存了诊断,但 CLI 只在启动时展示;宿主需要主动消费。

总体来看,这是一个"薄宿主 + 工厂闭包"的设计:把易变的 cwd 绑定状态交给工厂重建,把稳定的替换编排留在运行时。它没有试图让 AgentSession 支持原地切换,而是明确接受"替换就是换对象",这让状态边界和生命周期顺序更容易推理。

14. 关键代码索引

位置 内容
agent-session-runtime.ts:23 CreateAgentSessionRuntimeResult
agent-session-runtime.ts:35 CreateAgentSessionRuntimeFactory
agent-session-runtime.ts:46 SessionImportFileNotFoundError
agent-session-runtime.ts:74 AgentSessionRuntime 类
agent-session-runtime.ts:83 构造函数
agent-session-runtime.ts:97-115 services / session / cwd / diagnostics / modelFallbackMessage
agent-session-runtime.ts:117 setRebindSession
agent-session-runtime.ts:129 setBeforeSessionInvalidate
agent-session-runtime.ts:133 emitBeforeSwitch
agent-session-runtime.ts:150 emitBeforeFork
agent-session-runtime.ts:167 teardownCurrent
agent-session-runtime.ts:180 apply
agent-session-runtime.ts:187 finishSessionReplacement
agent-session-runtime.ts:196 switchSession
agent-session-runtime.ts:226 newSession
agent-session-runtime.ts:262 fork
agent-session-runtime.ts:359 importFromJsonl
agent-session-runtime.ts:404 dispose
agent-session-runtime.ts:420 createAgentSessionRuntime
agent-session-services.ts:25 AgentSessionRuntimeDiagnostic
agent-session-services.ts:73 AgentSessionServices
agent-session-services.ts:135 createAgentSessionServices
agent-session-services.ts:214 createAgentSessionFromServices
sdk.ts:191 createAgentSession
agent-session.ts:1397 AgentSession.dispose
agent-session.ts:1425 AgentSession.refreshContext
agent-session.ts:2464 AgentSession.abort
agent-session.ts:3288 AgentSession.bindExtensions
agent-session.ts:3690 AgentSession.reload
agent-session.ts:4405 createReplacedSessionContext
session-manager.ts:1057 SessionManager.newSession
session-manager.ts:1632 SessionManager.createBranchedSession
session-manager.ts:1755 SessionManager.create
session-manager.ts:1766 SessionManager.open
session-manager.ts:1804 SessionManager.inMemory
session-cwd.ts:54 assertSessionCwdExists
main.ts:733 CLI 工厂实现
main.ts:866 CLI 创建运行时
interactive-mode.ts:614-620 交互模式注册替换回调
interactive-mode.ts:2090 rebindCurrentSession
interactive-mode.ts:5769 handleResumeSession
interactive-mode.ts:6585 handleImportCommand
rpc-mode.ts:313 RPC 注册替换回调
rpc-mode.ts:603-628 RPC 的 switch/fork/clone 命令
print-mode.ts:70 Print 模式注册替换回调
examples/sdk/13-session-runtime.ts 最小宿主示例
docs/sdk.md:58 SDK 文档中的运行时说明
docs/extensions.md:212 扩展上下文与会话替换约束
相关推荐
墨心@2 小时前
Pi Services 层
agent·pi·coding agent·harness
hpoenixf13 小时前
从 Trace 到可信观测:我们的 Agent 可观测体系怎样一步步建立起来
人工智能·agent
hpoenixf13 小时前
从能读取到能研究:我们如何把 agent的 PDF tool升级为可追溯的多模态能力
agent
goehou13 小时前
Agent 接飞书实战:回调验签、事件去重与异步回复(附踩坑)
实战·飞书·agent·mcp·im集成
Rosanci14 小时前
多个 AI 会话同时改一个项目,怎样避免任务互相踩?
人工智能·agent·ai 编程
七夜zippoe17 小时前
Agent 接入企业微信/钉钉:渠道对接实
ai·钉钉·企业微信·agent·渠道对接
aichitang202417 小时前
QCU 2.0.0:补齐 Windows、Linux 与 GNOME Wayland
linux·运维·windows·ai·agent·ai编程
Autumn_ing18 小时前
AI Agent工作台能做什么?一个产研任务从资料到交付怎么走
ai·agent·产品经理·墨刀·ai agent·prd·墨刀ai客户端