
1. 一句话结论
AgentSessionRuntime 是"当前 AgentSession 的宿主"。它本身不执行对话,只负责在 /new、/resume、/fork、/import 这类会话替换操作中,按固定顺序完成:
- 取消检查;
- 结算并销毁旧会话;
- 通过工厂重建 cwd 绑定的服务和新
AgentSession; - 替换内部引用;
- 让宿主重新绑定订阅和扩展;
- 在
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 内部,会出现两个问题:
- 一个已经绑定 cwd A 的会话对象要变成 cwd B 的会话,必须替换自己依赖的大部分字段,状态边界不清晰;
- 启动路径和后续替换路径容易分叉,出现"启动时这样初始化,切换时那样初始化"的不一致。
当前方案把替换逻辑上移到一个薄宿主,并让启动和后续替换共用同一个工厂:
ts
type CreateAgentSessionRuntimeFactory = (options: {
cwd: string;
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;
工厂闭包持有进程级固定输入(CLI 参数、认证存储、扩展工厂等),每次调用时针对目标 cwd 重新创建 cwd 绑定服务。
一个具体链路:用户恢复一个保存在其他目录的会话。
switchSession(path)打开 JSONL,读出 header 中的 cwd;assertSessionCwdExists检查该目录是否仍存在;- 销毁当前会话;
- 工厂用目标 cwd 重新创建设置、资源加载器、模型运行时;
- 新
AgentSession从目标会话的历史中恢复模型和 thinking level; - 宿主重新绑定事件订阅和扩展。
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 的注释明确写了四步:
- 闭包持有进程级固定输入;
- 为目标 cwd 重建 cwd 绑定服务;
- 针对这些服务解析模型、thinking level、工具等会话选项;
- 创建
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() 的行为:
resolvePath(cwd),保证 cwd 是规范路径;agentDir缺省时使用getAgentDir();- 未注入
modelRuntime时创建新的ModelRuntime,读取agentDir/auth.json和agentDir/models.json; - 未注入
settingsManager时调用SettingsManager.create(cwd, agentDir); - 创建
DefaultResourceLoader并reload(); - 把扩展注册的 provider、native provider、virtual model 应用到
ModelRuntime,失败转为诊断; modelRuntime.refresh({ allowNetwork: false });- 应用扩展 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,
);
}
三个要点:
- 初始会话也要检查 cwd 是否存在,避免启动后才发现会话目录失效;
- 工厂接收的
options原样传入,初始调用不包含projectTrustContext; - 同一个工厂被保存在运行时上,后续所有替换复用,保证启动和替换走同一条创建路径。
构造函数是公开的,测试里也会直接 new AgentSessionRuntime(...)。直接构造会跳过初始 cwd 检查。
5.2 CLI 中的工厂实现
main.ts:733-864 是工厂的完整实现,可以分成六段:
-
项目信任
- 以 cwd 为 key 缓存信任结果;
- 如果该 cwd 有需要信任的项目资源且尚未解析,则在
resourceLoaderReloadOptions.resolveProjectTrust中交互或非交互解析; - 把结果写入
projectTrustByCwd。
-
设置
SettingsManager.create(cwd, agentDir, { projectTrusted });- 每次替换都创建新的 settings manager,使项目设置随 cwd 变化。
-
服务
createAgentSessionServices({ cwd, agentDir, settingsManager, ... });- 传入固定的 CLI 资源路径(extension/skill/prompt/theme)和扩展工厂;
- 传入 15 秒的模型运行时初始化超时。
-
诊断汇总
- 项目信任诊断;
- 服务诊断;
- 设置诊断;
- 扩展加载错误和警告。
-
模型与会话选项
- 从 CLI 参数或设置解析模型范围;
buildSessionOptions解析模型、thinking level、工具等;--api-key在模型确定后写入运行时。
-
创建会话
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();
}
顺序不能交换:
-
abort()必须在session_shutdown之前abort()会中止当前 agent run、重试、压缩、分支摘要,并等待会话 idle;- 被中止的 turn 会写入 tool result,避免旧会话留下悬空的 tool call;
- 测试
settles the active response before session replacement明确验证了这一点:替换前阻塞中的工具会得到持久化的 tool result。
-
session_shutdown必须在dispose()之前- 扩展需要在上下文仍然有效时释放资源;
dispose()之后ExtensionRunner会被 invalidate,旧 ctx 再访问会抛错。
-
beforeSessionInvalidate是同步回调- 它在 shutdown handler 完成后、
dispose()之前执行; - 交互模式用它
resetExtensionUI(),因为 TUI 组件的卸载不能等待事件循环; - 此时旧扩展上下文还没有失效,仍可读取旧会话信息。
- 它在 shutdown handler 完成后、
-
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());
}
}
顺序是固定的:
- 先
rebindSession,宿主把订阅、扩展绑定、UI 绑定切到新会话; - 再执行
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>;
}
行为:
- emit
session_before_switch { reason: "new" },此时targetSessionFile为undefined; - 记录旧会话文件;
- 从旧
SessionManager取 session 目录; - 旧会话持久化时创建新的持久化 manager,否则创建
SessionManager.inMemory(this.cwd); - 如果传入
parentSession,写入新会话 header; - teardown 旧会话,
session_shutdown.reason = "new"; - 工厂以当前
cwd创建新运行时; - 如果传入
setup,在新会话的SessionManager上执行,然后session.refreshContext()重建 finalized transcript; - rebind;
- 执行
withSession。
要点:
newSession不改变 cwd ,始终使用当前cwd;- session 目录沿用旧 manager 的目录,而不是重新计算默认目录;
- 内存会话继续保持内存模式;
setup在 rebind 之前执行,因此session_starthandler 能看到 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;
}
行为:
- emit
session_before_switch { reason: "resume", targetSessionFile: sessionPath }; - 记录旧会话文件;
SessionManager.open(sessionPath, undefined, cwdOverride);assertSessionCwdExists(sessionManager, this.cwd);- teardown,
session_shutdown.reason = "resume",target 为解析后的会话文件; - 工厂以
sessionManager.getCwd()创建运行时,session_start.reason = "resume",并携带previousSessionFile; - 如果提供了
projectTrustContextFactory,用目标 cwd 生成projectTrustContext传给工厂; - rebind;
- 执行
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 }
行为:
- emit
session_before_fork { entryId, position }; - 取消则直接返回,不校验 entry;
- 读取 entry:
position: "at":任意 entry 都合法,目标 leaf 是entry.id;position: "before":entry 必须是 user message,目标 leaf 是entry.parentId,同时提取用户文本作为selectedText;
- 记录旧会话文件;
- 根据是否持久化走不同路径。
持久化路径:
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?)
行为:
resolvePath(inputPath);- 文件不存在则抛
SessionImportFileNotFoundError; - 取当前 session 目录,不存在则递归创建;
- 目标路径默认为
sessionDir/basename(inputPath); - 如果目标路径就是源文件,标记
sourceAlreadyStored,不复制; - 否则在文件名冲突时追加
-1、-2等后缀; - emit
session_before_switch { reason: "resume", targetSessionFile: destinationPath }; - 取消则返回,不复制;
- 非原地导入时用
COPYFILE_EXCL复制,避免覆盖已有文件; SessionManager.open(destinationPath, sessionDir, cwdOverride);assertSessionCwdExists(sessionManager, this.cwd);- teardown,
session_shutdown.reason = "resume"; - 工厂以目标 cwd 创建运行时,
session_start.reason = "resume"; - 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。
交互模式捕获后:
- 显示提示,询问是否在当前 cwd 继续;
- 用户确认后,以
cwdOverride重新调用switchSession或importFromJsonl; - 用户取消则保持原会话。
非交互模式下直接报错并退出。
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 做:
- 取消旧订阅;
- 重置 program status;
- 应用运行时设置;
- 渲染会话状态并订阅新 agent(
renderBeforeBind时先渲染再绑定); bindCurrentSessionExtensions(),其中调用session.bindExtensions()并挂上命令上下文动作;- 如果绑定过程中会话又被替换,直接返回,避免把旧绑定挂到新会话上;
- 更新 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 宿主接入检查清单
- 工厂必须重建 cwd 绑定服务,并返回
services和diagnostics; - 工厂应使用参数中的
cwd和agentDir,不要读取全局 cwd; - 替换后必须重新绑定扩展和事件订阅;
- 旧订阅必须取消,否则会同时收到旧会话事件;
- 旧 ctx 和旧
pi不能在替换后继续使用; - 替换后应读取
runtime.diagnostics,否则新 cwd 的配置错误会被静默忽略; - 捕获替换异常并按 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 处理 |
测试中最值得注意的两个断言:
- 替换前必须结算 turn :旧会话中阻塞的工具在替换后仍留下持久化的
toolResult; - rebind 先于
withSession:事件顺序为shutdown → start → with,保证withSession看到的是已完成绑定的新会话。
13. 设计评价
优点
-
单一创建路径
启动、
/new、/resume、/fork、/import都走同一个工厂,避免初始化逻辑分叉。 -
清晰的职责边界
运行时只管理"当前会话 + 服务"的替换,不关心 CLI 参数、认证、项目信任;这些留在工厂闭包里。
-
销毁顺序经过设计
abort → shutdown → beforeSessionInvalidate → dispose覆盖了"结算数据、通知扩展、同步清理 UI、失效上下文"四个需求,顺序有测试保护。 -
显式的宿主契约
setRebindSession和withSession把"替换后重新绑定"和"替换后继续工作"分开,避免扩展在旧上下文上操作。 -
可取消
替换前的事件允许扩展拦截,且取消发生在所有副作用之前。
代价与风险
-
每次替换重建整套服务
对 CLI 是正确性优先;对频繁切换的宿主可能有明显成本,尤其是认证读取和资源发现。
-
没有回滚
工厂失败后只能退出或由宿主自行恢复。这是运行时最小的错误模型,但要求宿主把替换错误视为 fatal。
-
宿主契约靠约定而非类型强制
setRebindSession是可选的。忘记设置且不手动绑定,会导致新会话没有session_start、没有扩展命令、没有事件订阅。SDK 示例用注释提醒这一点。 -
并发和重入没有保护
文档用"command-only"约束规避,而不是运行时强制。
-
诊断在替换后容易被忽略
运行时保存了诊断,但 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 |
扩展上下文与会话替换约束 |