@zhushanwen/pi-rename-session 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zhushanwen/pi-rename-session
2
2
 
3
- Pi rename-session 扩展 — 三种触发模式下自动/自主为会话生成 slug 式标题并落库(`setSessionName`),让 session 列表摆脱默认的日期/序号占位,一眼可辨。
3
+ Pi rename-session 扩展 — 三种触发模式下自动/自主为会话生成 slug 式标题并写入(`setSessionName`),让 session 列表摆脱默认的日期/序号占位,一眼可辨。
4
4
 
5
5
  ## 功能
6
6
 
@@ -11,8 +11,8 @@ Pi rename-session 扩展 — 三种触发模式下自动/自主为会话生成 s
11
11
  - **触发时机(first-stop 路径)**:只在 round 的最终 turn(`stopReason === "stop"`)评估——工具中间轮 / error / aborted / length 轮不评估,error 轮延迟到下一个成功轮命名
12
12
  - **两段输入(自动路径)**:`[user(首条 prompt), assistant(最终回复)]` 两段信号(各截断 4000 码点),不含 toolCall/toolResult 过程数据,token 成本不随工具数增长;first-prompt 模式无 assistant 段(降级两条)
13
13
  - **独立选模**:标题生成用独立的 `ModelSelector` 配置(仅支持 `ref` 精确指定 provider/model);**未配置(空 ref)时跟随会话主模型**(`ctx.model`),开箱即用
14
- - **可靠性行为**:固定 30s 超时;落库前重查手动名(防覆盖 LLM 调用窗口内的竞态,agent-tool 工具路径除外——显式改名允许覆盖);任何失败静默跳过保留原 label,绝不阻断 agent 循环
15
- - 标题直接 `setSessionName` 落库,不进 session history(不污染对话记录)
14
+ - **可靠性行为**:固定 30s 超时;写入前重查手动名(防覆盖 LLM 调用窗口内的竞态,agent-tool 工具路径除外——显式改名允许覆盖);任何失败静默跳过保留原 label,绝不阻断 agent 循环
15
+ - 标题直接 `setSessionName` 写入,不进 session history(不污染对话记录)
16
16
  - **子 session 自动排除**:subagent 子进程 session 不触发 rename(避免给临时产物起名)
17
17
 
18
18
  ## 安装
@@ -51,7 +51,7 @@ pi install npm:@zhushanwen/pi-rename-session
51
51
  |---|---|---|---|
52
52
  | `enabled` | `boolean` | `false` | 自动重命名开关(受 flag 文件覆盖,见下) |
53
53
  | `model` | `ModelSelector` | `{ "type": "ref", "ref": "" }` | 标题生成模型,仅支持精确指定 `{type:"ref", ref:"provider/modelId"}`;**空 ref 跟随会话主模型**(开箱即用),非空但解析失败(配错)才静默跳过 |
54
- | `mode` | `"first-prompt" \| "first-stop" \| "agent-tool"` | `"first-stop"` | 触发模式(三值互斥):首条请求即命名 / 首个成功 round 末命名(现状)/ agent 自主经 `rename_session` 工具命名。事件面每次事件 live 读(GUI 切换对活跃 session 的自动命名即时生效);工具注册面在 extension 工厂执行时求值(每 session 创建一次——taiji 每 session 一个 pi 进程;原生 pi CLI 同进程 /fork、/session 新建 session 亦重新求值),session 存活期内不回溯(pi 无 unregisterTool),切换后已存活 session 的残留工具由 execute 内 live mode 守卫拒绝并给恢复指引 |
54
+ | `mode` | `"first-prompt" \| "first-stop" \| "agent-tool"` | `"first-stop"` | 触发模式(三值互斥):首条请求即命名 / 首个成功 round 末命名(现状)/ agent 自主经 `rename_session` 工具命名。事件路径每次事件 live 读(GUI 切换对活跃 session 的自动命名即时生效);工具注册点在 extension 工厂执行时求值(每 session 创建一次——taiji 每 session 一个 pi 进程;原生 pi CLI 同进程 /fork、/session 新建 session 亦重新求值),session 存活期内不回溯(pi 无 unregisterTool),切换后已存活 session 的残留工具由 execute 内 live mode 检查拒绝并给恢复指引 |
55
55
  | `maxTitleLength` | `number` | `50` | 标题最大长度(Unicode 码点数,须正整数) |
56
56
  | `thinkingLevel` | `ModelThinkingLevel` | `"off"` | 标题 LLM 的 thinking 级别(`off` = 不传 reasoning,provider 默认) |
57
57
  | `style` | `"slug" \| "sentence" \| "emoji" \| "custom"` | `"slug"` | 标题提示词风格:非 custom 枚举 = 内置词表(随版本升级自动跟随);`custom` = 消费 `customInstruction`(taiji 设置页「会话重命名」可视化配置) |
@@ -77,16 +77,16 @@ pi install npm:@zhushanwen/pi-rename-session
77
77
 
78
78
  ## 工作原理
79
79
 
80
- 三个入口共用同一条落库管道(`callRenameLLM` → 防覆盖重查 → `setSessionName`),按 `mode` 分派:
80
+ 三个入口共用同一条写入管道(`callRenameLLM` → 防覆盖重查 → `setSessionName`),按 `mode` 分派:
81
81
 
82
- 1. **入口分派(事件面 live 读 mode)**:`message_end` 入口(first-prompt)过滤 `role === "user"`;`turn_end` 入口(first-stop)按下方流程;`agent-tool` 模式不激活自动命名逻辑(两 handler 常驻注册、命中即静默返回),改为 extension 工厂执行时(每 session 创建一次)注册 `rename_session` 工具。
82
+ 1. **入口分派(事件路径 live 读 mode)**:`message_end` 入口(first-prompt)过滤 `role === "user"`;`turn_end` 入口(first-stop)按下方流程;`agent-tool` 模式不激活自动命名逻辑(两 handler 常驻注册、命中即静默返回),改为 extension 工厂执行时(每 session 创建一次)注册 `rename_session` 工具。
83
83
  2. **开关 + subagent 过滤**(自动路径共用):开关关闭(flag 不存在且 `enabled=false`)直接返回;session 路径含 `subagents` 段视为子进程 session,跳过。
84
- 3. **O(1) 快速路径(first-stop)**:只有 `stopReason === "stop"` 的 turn 才继续——**rename 一定在 round 末触发**(最终 turn 的 message 即最终 assistant 回复,final text 零遍历可得),不会在首个 iteration 中途命名。first-prompt 入口的对应守卫:首条 user 判定 = handler 执行时 `getEntries()` 中 user message 计数 === 0(pi 的 extension handler 先于该条 message 的 entries append 执行,本条即 session 首条 user;steering/follow-up 消息到达时首条已入 entries,天然不重复触发)。
84
+ 3. **O(1) 快速路径(first-stop)**:只有 `stopReason === "stop"` 的 turn 才继续——**rename 一定在 round 末触发**(最终 turn 的 message 即最终 assistant 回复,final text 零遍历可得),不会在首个 iteration 中途命名。first-prompt 入口的对应检查:首条 user 判定 = handler 执行时 `getEntries()` 中 user message 计数 === 0(pi 的 extension handler 先于该条 message 的 entries append 执行,本条即 session 首条 user;steering/follow-up 消息到达时首条已入 entries,天然不重复触发)。
85
85
  4. **首 round 判定(first-stop)**:session entries 中成功(stop)assistant 回复数 === 1 才触发(后续 round 不重复 rename;error 轮的 assistant 回复不计数,延迟到下一个成功轮)。
86
86
  5. **两段输入构造**:`[user(首条 prompt), assistant(最终回复文本), user(instruction)]`——任务意图 + 轮次结论恰好与标题语义对齐,不含 toolCall/toolResult 过程数据;两段文本各截断 4000 Unicode 码点(中文场景约 4k token/段,成本可控且不随工具数增长)。assistant 段为空(first-prompt 模式 / 纯工具结束的 round)时降级为两条——标题主信号本就是 prompt。
87
87
  6. **LLM 生成 slug 标题**:独立精简 system prompt(<200 字符的 slug 词组约束,非整个 agent prompt)+ instruction(正反例 few-shot,作为追加 user message 发送)+ `tools: []` + `maxTokens: 64`,按 `config.model` 独立选模(空 ref 跟随会话主模型)发起一次 LLM 调用;固定 30s 超时(超时归一为失败,走静默跳过)。
88
- 7. **落库**:cleanTitle 清洗(去首尾引号 / markdown 强调标记 / 句尾标点、空白归一、按码点截断)后 `setSessionName` 写入。**落库前重查** `pi.getSessionName()`——LLM 调用窗口(2-30s)内用户手动命名的竞态由此兜住,已有名则 skip 不覆盖。**不**写入 session history,对话记录不受影响。
89
- 8. **`rename_session` 工具(agent-tool)**:execute 内守卫链依次为——先拒绝 subagent session(子会话是临时产物,subagent 排除守卫覆盖 message_end / turn_end / 工具三入口,C-ext-21);再 live 读 config 守卫——mode 已切走时拒绝(错误文案含恢复指引:切回 agent-tool / 手动改名);title 经 cleanTitle 清洗,空值拒绝;非空直接 `setSessionName` **不走防覆盖守卫**(agent 显式调用 = 代表用户的意图,允许覆盖任何既有名,含自动名/语义名)。
88
+ 7. **写入**:cleanTitle 清洗(去首尾引号 / markdown 强调标记 / 句尾标点、空白归一、按码点截断)后 `setSessionName` 写入。**写入前重查** `pi.getSessionName()`——LLM 调用窗口(2-30s)内用户手动命名的竞态由此兜住,已有名则 skip 不覆盖。**不**写入 session history,对话记录不受影响。
89
+ 8. **`rename_session` 工具(agent-tool)**:execute 内检查链依次为——先拒绝 subagent session(子会话是临时产物,subagent 排除检查覆盖 message_end / turn_end / 工具三入口,C-ext-21);再 live 读 config 检查——mode 已切走时拒绝(错误文案含恢复指引:切回 agent-tool / 手动改名);title 经 cleanTitle 清洗,空值拒绝;非空直接 `setSessionName` **不走防覆盖检查**(agent 显式调用 = 代表用户的意图,允许覆盖任何既有名,含自动名/语义名)。
90
90
 
91
91
  ### 可靠性行为
92
92
 
@@ -97,12 +97,12 @@ rename 是 best-effort 副作用,任何失败静默跳过、绝不阻断 agent
97
97
  | 中间 iteration(工具轮)的 turn_end | skip(stopReason=toolUse),round 末才评估 |
98
98
  | error / aborted / length 轮 | skip(stopReason=<X>),error 上下文不用于命名,延迟到下一个成功轮 |
99
99
  | 非 round-1(成功回复数 ≠ 1) | skip(count=N),一次性语义 |
100
- | 非 first-prompt 模式收到 user message_end | skip(mode=<mode>),事件面 live 分派 |
100
+ | 非 first-prompt 模式收到 user message_end | skip(mode=<mode>),事件路径 live 分派 |
101
101
  | 非 session 首条 user message(first-prompt) | skip(userCount=N),一次性语义 |
102
102
  | LLM 调用失败 / 超过 30s | 记录失败日志,保留原 label(不做重试;用户可手动命名) |
103
103
  | 标题清洗后为空 | skip(title empty) |
104
- | 落库前发现已有手动名(自动路径) | skip(name exists),不覆盖 |
105
- | `rename_session` 工具被调用但 mode 已切走 | execute 守卫拒绝(isError 文案含恢复指引) |
104
+ | 写入前发现已有手动名(自动路径) | skip(name exists),不覆盖 |
105
+ | `rename_session` 工具被调用但 mode 已切走 | execute 检查拒绝(isError 文案含恢复指引) |
106
106
  | 标题模型不可用 | 记日志静默跳过 |
107
107
 
108
108
  ### 日志通道(appendEntry 常开 + debug 文件日志)
@@ -118,22 +118,22 @@ rename 是 best-effort 副作用,任何失败静默跳过、绝不阻断 agent
118
118
  |---|---|---|---|
119
119
  | 1 | `skip: stopReason=<r>` | turn_end handler(带 `turnIndex=<n>`) | 快速路径拦截(toolUse/error/aborted/length) |
120
120
  | 2 | `skip: count=<n>` | turn_end handler(带 turnIndex) | 非首成功 round |
121
- | 3 | `skip: name exists` | handler `.then()`(turn_end 原样 / message_end 带 `firstPrompt` 前缀) | 落库前防覆盖命中 |
122
- | 4 | `renamed to "<title>"` | turn_end handler(带 turnIndex)/ message_end handler(`firstPrompt renamed to "..."`) | 标题生成并落库成功(`setSessionName` 之后打出;竞态命中时只打 #3,无此条) |
121
+ | 3 | `skip: name exists` | handler `.then()`(turn_end 原样 / message_end 带 `firstPrompt` 前缀) | 写入前防覆盖命中 |
122
+ | 4 | `renamed to "<title>"` | turn_end handler(带 turnIndex)/ message_end handler(`firstPrompt renamed to "..."`) | 标题生成并写入成功(`setSessionName` 之后打出;竞态命中时只打 #3,无此条) |
123
123
  | 5 | `skip: no user prompt` | llm | session 无 user message(理论不发生) |
124
124
  | 6 | `skip: title empty` | llm | cleanTitle 清洗后为空 |
125
125
  | 7 | `LLM request messages: <JSON>` | llm | 传给 callLLM 的 messages 内省(role + text 的 head 200 码点 + … + tail 100 码点预览,截断单位与 truncateForTitle 统一为 Unicode 码点),在请求发起前打出 |
126
126
  | 8 | `rename with model <provider>/<id>` | llm | 成功路径模型记录(原常开日志;为避免污染 Pi 输入框改为 debug 输出,带 t=ISO 时间戳) |
127
- | 9 | `skip: mode=<mode>` | turn_end handler(`turnIndex=<n>`)/ message_end handler(`firstPrompt skip: mode=...`) | mode 分派拦截:本入口对当前 mode 不负责(事件面 live 读,切走即停) |
127
+ | 9 | `skip: mode=<mode>` | turn_end handler(`turnIndex=<n>`)/ message_end handler(`firstPrompt skip: mode=...`) | mode 分派拦截:本入口对当前 mode 不负责(事件路径 live 读,切走即停) |
128
128
  | 10 | `skip: userCount=<n>` | message_end handler(`firstPrompt skip: userCount=...`) | 非 session 首条 user message(first-prompt 一次性语义) |
129
129
  | 11 | `skip: empty prompt` | message_end handler(`firstPrompt skip: empty prompt`) | user message 载荷无文本(理论不发生) |
130
- | 12 | `tool renamed to "<title>"` | rename_session 工具 execute | agent-tool 路径落库成功(`setSessionName` 之后打出,无防覆盖前缀) |
130
+ | 12 | `tool renamed to "<title>"` | rename_session 工具 execute | agent-tool 路径写入成功(`setSessionName` 之后打出,无防覆盖前缀) |
131
131
 
132
132
  另有三条**非 debug 常开**日志(无条件经 `logger.warn` 落 appendEntry entry,任意环境可 `grep "rename-session:log" <session.jsonl>` 排障):`rename LLM call failed`(`logger.warn(msg, { error })` 形态,error 详情在结构化 data 字段;超时时 llm-shared callLLM 内部的 extractText 将空错误文本归一为 `unknown error`——extension 侧 `result.error ?? "unknown error"` 只兜 null/undefined,空串兜底发生在 llm-shared 层)、`model not available, skipping`(选模失败)、`title empty after clean, skipping rename`(调用成功但标题清洗后为空,结构化 data 含 `model` / `stopReason` / `raw`(≤100 码点标题候选预览)——`stopReason=length` 表示输出预算被 thinking 吃尽,是上调 `maxTokens` 常量的数据信号)。handler 侧日志 message 带 `t=<ISO时间>` 与 `turnIndex`;llm 侧带 `t=<ISO时间>`、无 turnIndex。文件日志通道(`<agentDir>/logs/`)需 `TAIJI_AGENT_DEBUG=1`,独立 pi 用户排障优先 grep session 文件(无需复跑)。
133
133
 
134
134
  ## E2E 验收
135
135
 
136
- E2E 是本地人工触发的验收资产(真实 pi 进程 + 真实模型,不进常规 CI),覆盖七个场景:A1 触发时机证据链(流序/内容匹配/负向/行序/结构五重——结构断言 = 仅一条 LLM request + [user,assistant,user] 三元组)、A2 slug 风格 ×3、A3 防覆盖(静态/竞态/一次性)、A4 error 轮两阶段(`--session` 续跑)、A5 超时兜底(hang provider)、A6 first-prompt 模式(触发时点 = 首条 assistant `message_start` 前 rename LLM 请求已发出 + 标题仅基于 prompt + 后续 round 不再改名;完成序不做 gate)、A7 agent-tool 模式(工具改名即时落库覆盖既有名 + first-stop 对照进程零 `rename_session` toolCall entry)。
136
+ E2E 是本地人工触发的验收资产(真实 pi 进程 + 真实模型,不进常规 CI),覆盖七个场景:A1 触发时机证据链(流序/内容匹配/负向/行序/结构五重——结构断言 = 仅一条 LLM request + [user,assistant,user] 三元组)、A2 slug 风格 ×3、A3 防覆盖(静态/竞态/一次性)、A4 error 轮两阶段(`--session` 续跑)、A5 超时兜底(hang provider)、A6 first-prompt 模式(触发时点 = 首条 assistant `message_start` 前 rename LLM 请求已发出 + 标题仅基于 prompt + 后续 round 不再改名;完成序不做 gate)、A7 agent-tool 模式(工具改名即时写入覆盖既有名 + first-stop 对照进程零 `rename_session` toolCall entry)。
137
137
 
138
138
  ```bash
139
139
  cd extensions/universal/rename-session
@@ -171,7 +171,7 @@ rename-session/
171
171
  ├── skills/rename-session-ext-config/SKILL.md # 配置指南(pi 内 agent 可发现)
172
172
  └── src/
173
173
  ├── index.ts # 工厂入口(message_end/turn_end handler 按模式分派 + rename_session 工具注册 + /auto-rename 命令)
174
- ├── landing.ts # landTitle:两入口共用落库管道(起 LLM → 防覆盖重查 → setSessionName → renamed to 日志 + usage 落账)
174
+ ├── landing.ts # landTitle:两入口共用写入管道(起 LLM → 防覆盖重查 → setSessionName → renamed to 日志 + usage 记录)
175
175
  ├── commands.ts # /auto-rename on|off|status 命令(enable/disable 别名)
176
176
  ├── llm.ts # callRenameLLM / 两段输入构造 / debug 内省 / 超时
177
177
  ├── pure.ts # 纯函数(配置 / 首轮计数 / cleanTitle)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-rename-session",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -35,10 +35,10 @@
35
35
  "pi-package"
36
36
  ],
37
37
  "dependencies": {
38
- "@zhushanwen/extension-protocol": "0.21.0",
38
+ "@zhushanwen/extension-protocol": "0.24.0",
39
39
  "@zhushanwen/pi-ext-guards": "0.5.0",
40
40
  "@zhushanwen/pi-extension-logger": "0.6.2",
41
- "@zhushanwen/pi-llm-shared": "0.11.3"
41
+ "@zhushanwen/pi-llm-shared": "0.12.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@vitest/coverage-v8": "^4.1.9",
@@ -30,8 +30,8 @@ description: "配置 @zhushanwen/pi-rename-session(会话自动重命名)时
30
30
  - **自动模式一次性语义**:每个 session 最多自动重命名一次;已过触发窗口的存量 session 不会回溯重命名——开启后只对之后新建的 session 生效
31
31
  - 工具中间轮(`stopReason === "toolUse"`)不评估;error/aborted/length 轮延迟到下一个成功轮再命名(first-stop)
32
32
  - 首个成功 round 时 LLM 调用失败 → 静默跳过保留原标题,不重试(first-stop 有 error 轮延迟语义;first-prompt 触发窗口唯一,错过即无自动机会)
33
- - **mode 求值时点**:事件面(自动命名分派)每次事件 live 读——切换对活跃 session 的自动命名即时生效;`rename_session` 工具注册面只在 pi 进程启动加载 extension 时求值一次——切换后已存活 session 的工具清单不回溯,残留工具被调用时 execute 内 live 守卫拒绝(错误文案含恢复指引)
34
- - `rename_session` 工具改名**不走防覆盖守卫**——agent 显式调用等同手动 rename,允许覆盖任何既有名(含自动名/语义名)
33
+ - **mode 求值时点**:事件路径(自动命名分派)每次事件 live 读——切换对活跃 session 的自动命名即时生效;`rename_session` 工具注册点只在 pi 进程启动加载 extension 时求值一次——切换后已存活 session 的工具清单不回溯,残留工具被调用时 execute 内 live 检查拒绝(错误文案含恢复指引)
34
+ - `rename_session` 工具改名**不走防覆盖检查**——agent 显式调用等同手动 rename,允许覆盖任何既有名(含自动名/语义名)
35
35
  - steering/follow-up 队列消息不满足「session 首条 user」判定,不会误触发(first-prompt)
36
36
 
37
37
  > 改完配置「没看到 session 被重命名」的常见原因:当前 session 已过触发窗口(首条 user 已发出 / 首个成功 round 已完成)。新建一个 session 测试。
@@ -73,7 +73,7 @@ interface RenameSessionConfig {
73
73
 
74
74
  ### style + customInstruction(标题提示词风格)
75
75
 
76
- - `style` 枚举 `slug` / `sentence` / `emoji` / `custom`,默认 `"slug"`(现状行为,零迁移):非 custom 枚举由内置词表(llm.ts `BUILTIN_TEMPLATES`)承载 user 指令,随 extension 版本升级自动跟随;`custom` 消费 `customInstruction`(taiji 设置页「会话重命名」可视化配置的写入面)
76
+ - `style` 枚举 `slug` / `sentence` / `emoji` / `custom`,默认 `"slug"`(现状行为,零迁移):非 custom 枚举由内置词表(llm.ts `BUILTIN_TEMPLATES`)承载 user 指令,随 extension 版本升级自动跟随;`custom` 消费 `customInstruction`(taiji 设置页「会话重命名」可视化配置的写侧)
77
77
  - `customInstruction`:自定义自由文本,**空串/全空白回落内置 slug 默认**(「空 = 内置默认」语义,不要把默认文本写死进配置);超 2000 码点防御截断(与 runtime 写侧/runtime RENAME_INSTRUCTION_MAX_LENGTH 同限)
78
78
  - systemPrompt 分流:非 custom → slug 风格 systemPrompt(与词表自洽);custom 且指令非空 → 「只输出标题文本」通用骨架(风格权威 = 用户指令,消解双处风格冲突)
79
79
  - 缺失/非法 `style` 回落 `"slug"`;非 string `customInstruction` 回落空串(粒度容错,坏字段不影响其他字段)
@@ -11,7 +11,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
11
11
  }));
12
12
 
13
13
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
14
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
17
17
  // mock llm-shared:只 override resolveModel + callLLM(callRenameLLM 的依赖),
@@ -50,9 +50,9 @@ interface MockSetup {
50
50
  setSessionNameMock: ReturnType<typeof vi.fn>;
51
51
  /** 防覆盖检查读 pi.getSessionName(),非 ctx——默认未命名(undefined)。 */
52
52
  getSessionNameMock: ReturnType<typeof vi.fn>;
53
- /** usage 落账入口:landing.ts 注入回调体内调 pi.appendEntry,接线断言用(时点与 catch 归属契约见 llm.ts)。 */
53
+ /** usage 记录入口:landing.ts 注入回调体内调 pi.appendEntry,接线断言用(时点与 catch 归属契约见 llm.ts)。 */
54
54
  appendEntryMock: ReturnType<typeof vi.fn>;
55
- /** registerTool mock(agent-tool 注册面接线断言用)。 */
55
+ /** registerTool mock(agent-tool 注册点接线断言用)。 */
56
56
  registerToolMock: ReturnType<typeof vi.fn>;
57
57
  turnEndHandler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
58
58
  /** message_end handler(first-prompt 入口)。 */
@@ -218,7 +218,7 @@ describe("renameSessionExtension", () => {
218
218
  vi.mocked(loadRenameConfig).mockReset();
219
219
  vi.mocked(resolveModel).mockReset();
220
220
  vi.mocked(callLLM).mockReset();
221
- // 工厂注册面在 load 时读一次 config(工具注册判定)——工厂调用前必须有可用返回值
221
+ // 工厂注册点在 load 时读一次 config(工具注册判定)——工厂调用前必须有可用返回值
222
222
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
223
223
  setup = createMockPi();
224
224
  renameSessionExtension(setup.pi);
@@ -230,7 +230,7 @@ describe("renameSessionExtension", () => {
230
230
  expect(setup.pi.on).toHaveBeenCalledWith("message_end", expect.any(Function));
231
231
  expect(setup.turnEndHandler).toBeTypeOf("function");
232
232
  expect(setup.messageEndHandler).toBeTypeOf("function");
233
- // first-stop(ENABLED_CONFIG)非 agent-tool → 工具不注册(注册面 load 求值时点)
233
+ // first-stop(ENABLED_CONFIG)非 agent-tool → 工具不注册(注册点 load 求值时点)
234
234
  expect(setup.registerToolMock).not.toHaveBeenCalled();
235
235
  });
236
236
 
@@ -265,7 +265,7 @@ describe("renameSessionExtension", () => {
265
265
  });
266
266
 
267
267
  // ────────────────────────────────────────────────────
268
- // message_end 入口三模式分派(live 读 mode / first-prompt 守卫链)
268
+ // message_end 入口三模式分派(live 读 mode / first-prompt 检查链)
269
269
  // ────────────────────────────────────────────────────
270
270
 
271
271
  it("TC-M1: first-prompt 模式首条 user message_end → callLLM 发起,prompt 从 event 载荷取(entries 空,finalText 空走两条降级)", async () => {
@@ -283,7 +283,7 @@ describe("renameSessionExtension", () => {
283
283
  // 不等回复:finalText 空 → 两条 [user(promptText), user(instruction)],无 assistant 条目
284
284
  expect(opts.messages.map((m) => m.role)).toEqual(["user", "user"]);
285
285
  expect(opts.messages[0].content[0].text).toBe("帮我修复登录超时,并补上单测");
286
- // 落库走同一管道(防覆盖重查 → setSessionName → renamed to)
286
+ // 写入走同一管道(防覆盖重查 → setSessionName → renamed to)
287
287
  await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("修复登录超时"));
288
288
  });
289
289
 
@@ -337,7 +337,7 @@ describe("renameSessionExtension", () => {
337
337
  expect(warnText(warnSpy)).toContain("firstPrompt skip: mode=first-stop");
338
338
  });
339
339
 
340
- it("TC-M6: agent-tool 模式 message_end(user) 与 turn_end 均不触发自动命名(事件面 live 分派,V10 A 段「切走即停」)", async () => {
340
+ it("TC-M6: agent-tool 模式 message_end(user) 与 turn_end 均不触发自动命名(事件路径 live 分派,V10 A 段「切走即停」)", async () => {
341
341
  const warnSpy = debugWarnSpy();
342
342
  vi.mocked(loadRenameConfig).mockReturnValue(AGENT_TOOL_CONFIG);
343
343
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
@@ -351,7 +351,7 @@ describe("renameSessionExtension", () => {
351
351
  expect(log).toContain(`turnIndex=${FIRE_TURN_INDEX} skip: mode=agent-tool`);
352
352
  });
353
353
 
354
- it("TC-M7: first-prompt 模式 subagents 路径 → isSubagentSession 早退(守卫链复用,C-ext-21 覆盖新入口)", async () => {
354
+ it("TC-M7: first-prompt 模式 subagents 路径 → isSubagentSession 早退(检查链复用,C-ext-21 覆盖新入口)", async () => {
355
355
  vi.mocked(loadRenameConfig).mockReturnValue(FIRST_PROMPT_CONFIG);
356
356
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
357
357
 
@@ -364,7 +364,7 @@ describe("renameSessionExtension", () => {
364
364
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
365
365
  });
366
366
 
367
- it("TC-M8: first-prompt 模式 enabled=false → 不触发(开关守卫优先于 mode)", async () => {
367
+ it("TC-M8: first-prompt 模式 enabled=false → 不触发(开关检查优先于 mode)", async () => {
368
368
  vi.mocked(loadRenameConfig).mockReturnValue({ ...FIRST_PROMPT_CONFIG, enabled: false });
369
369
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
370
370
 
@@ -374,7 +374,7 @@ describe("renameSessionExtension", () => {
374
374
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
375
375
  });
376
376
 
377
- it("TC-M9: first-prompt 落库前防覆盖重查——LLM 窗口内已有名 → skip: name exists,不落库", async () => {
377
+ it("TC-M9: first-prompt 写入前防覆盖重查——LLM 窗口内已有名 → skip: name exists,不写入", async () => {
378
378
  const warnSpy = debugWarnSpy();
379
379
  vi.mocked(loadRenameConfig).mockReturnValue(FIRST_PROMPT_CONFIG);
380
380
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
@@ -444,7 +444,7 @@ describe("renameSessionExtension", () => {
444
444
  const warnSpy = debugWarnSpy();
445
445
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
446
446
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
447
- // ok:false 即可满足断言需要(callLLM 被调 + 不落库),聚焦触发判定本身
447
+ // ok:false 即可满足断言需要(callLLM 被调 + 不写入),聚焦触发判定本身
448
448
  vi.mocked(callLLM).mockResolvedValue({ ok: false, error: "by-design" });
449
449
 
450
450
  const FINAL_TEXT = "最终回复:已修复登录超时";
@@ -476,7 +476,7 @@ describe("renameSessionExtension", () => {
476
476
  await fire(setup, createMockCtx({ entries: afterErrorRound }), stopMessage);
477
477
  await vi.waitFor(() => expect(callLLM).toHaveBeenCalledTimes(2));
478
478
 
479
- // 组3:纯 error 轮(stop entry 未落库的异常防御——真实时序 stop turn_end 时 count>=1 恒成立)
479
+ // 组3:纯 error 轮(stop entry 未写入的异常防御——真实时序 stop turn_end 时 count>=1 恒成立)
480
480
  // → 不触发 + skip: count=0
481
481
  const errorOnly = [
482
482
  { type: "message", message: { role: "user", content: "继续修复" } },
@@ -540,14 +540,14 @@ describe("renameSessionExtension", () => {
540
540
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
541
541
  });
542
542
 
543
- it("LTC10: callLLM 返回 {ok:true,content} → cleanTitle 后 setSessionName 落库", async () => {
543
+ it("LTC10: callLLM 返回 {ok:true,content} → cleanTitle 后 setSessionName 写入", async () => {
544
544
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
545
545
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
546
546
  vi.mocked(callLLM).mockResolvedValue({ ok: true, content: " 修复登录bug " });
547
547
 
548
548
  await fire(setup, createMockCtx());
549
549
  // handler 内 callRenameLLM 是 detached promise(fire-and-forget),fire 立即 resolve;
550
- // 需等 detached promise settle 后再断言落库结果。
550
+ // 需等 detached promise settle 后再断言写入结果。
551
551
  await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("修复登录bug"));
552
552
 
553
553
  expect(callLLM).toHaveBeenCalledTimes(1);
@@ -585,10 +585,10 @@ describe("renameSessionExtension", () => {
585
585
  });
586
586
 
587
587
  // ────────────────────────────────────────────────────
588
- // TC3 防覆盖:落库前重查(含 LLM 调用窗口竞态)
588
+ // TC3 防覆盖:写入前重查(含 LLM 调用窗口竞态)
589
589
  // ────────────────────────────────────────────────────
590
590
 
591
- it("TC3: LLM 调用窗口内手动命名 → 落库前重查命中,不调 setSessionName(skip: name exists)", async () => {
591
+ it("TC3: LLM 调用窗口内手动命名 → 写入前重查命中,不调 setSessionName(skip: name exists)", async () => {
592
592
  const warnSpy = debugWarnSpy();
593
593
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
594
594
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
@@ -604,18 +604,18 @@ describe("renameSessionExtension", () => {
604
604
  await fire(setup, createMockCtx());
605
605
  await vi.waitFor(() => expect(callLLM).toHaveBeenCalledTimes(1));
606
606
 
607
- // 竞态窗口内:用户手动 /name 命名(落库前重查必须看到它)
607
+ // 竞态窗口内:用户手动 /name 命名(写入前重查必须看到它)
608
608
  setup.getSessionNameMock.mockReturnValue("我的手动名字");
609
609
 
610
610
  resolveLLM({ ok: true, content: "自动生成的标题" });
611
611
  await vi.waitFor(() => expect(warnText(warnSpy)).toContain("skip: name exists"));
612
612
 
613
613
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
614
- // 移位契约:renamed to 在 setSessionName 之后才打——竞态命中(未落库)时该日志不出现
614
+ // 移位契约:renamed to 在 setSessionName 之后才打——竞态命中(未写入)时该日志不出现
615
615
  expect(warnText(warnSpy)).not.toContain("renamed to");
616
616
  });
617
617
 
618
- it("TC3 对照组: getSessionName 始终 undefined → 正常落库 + renamed to 日志(setSessionName 之后、handler 侧带 turnIndex)", async () => {
618
+ it("TC3 对照组: getSessionName 始终 undefined → 正常写入 + renamed to 日志(setSessionName 之后、handler 侧带 turnIndex)", async () => {
619
619
  const warnSpy = debugWarnSpy();
620
620
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
621
621
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
@@ -630,7 +630,7 @@ describe("renameSessionExtension", () => {
630
630
  expect(String(warnSpy.mock.calls[renamedIdx][0])).toMatch(
631
631
  new RegExp(`^t=\\d{4}-\\d{2}-\\d{2}T.* turnIndex=${FIRE_TURN_INDEX} renamed to "自动生成的标题"$`),
632
632
  );
633
- // 移位契约(时序):日志调用序晚于 setSessionName——先落库后报捷
633
+ // 移位契约(时序):日志调用序晚于 setSessionName——先写入后报捷
634
634
  expect(warnSpy.mock.invocationCallOrder[renamedIdx]).toBeGreaterThan(
635
635
  setup.setSessionNameMock.mock.invocationCallOrder[0],
636
636
  );
@@ -648,12 +648,12 @@ describe("renameSessionExtension", () => {
648
648
  vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
649
649
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
650
650
 
651
- // 组1:有内容 → 全流程落库
651
+ // 组1:有内容 → 全流程写入
652
652
  vi.mocked(callLLM).mockResolvedValueOnce({ ok: true, content: "标题一" });
653
653
  await fire(setup, createMockCtx());
654
654
  await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("标题一"));
655
655
 
656
- // 组2:空内容 → cleanTitle 空 → 返回 null 不落库
656
+ // 组2:空内容 → cleanTitle 空 → 返回 null 不写入
657
657
  vi.mocked(callLLM).mockResolvedValueOnce({ ok: true, content: " " });
658
658
  await fire(setup, createMockCtx());
659
659
  await vi.waitFor(() => expect(callLLM).toHaveBeenCalledTimes(2));
@@ -690,7 +690,7 @@ describe("renameSessionExtension", () => {
690
690
  });
691
691
 
692
692
  // ────────────────────────────────────────────────────
693
- // usage 落账接线(appendUsageEntry 回调注入;时点与 catch 归属契约见 llm.ts)
693
+ // usage 记录接线(appendUsageEntry 回调注入;时点与 catch 归属契约见 llm.ts)
694
694
  // ────────────────────────────────────────────────────
695
695
 
696
696
  /** 合法 Usage 夹具(pi-ai Usage 全必填字段,消除 unsafe-cast 强断言)。 */
@@ -703,7 +703,7 @@ const STUB_USAGE: Usage = {
703
703
  cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0, total: 0.03 },
704
704
  };
705
705
 
706
- describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
706
+ describe("usage 记录接线(appendUsageEntry 回调注入)", () => {
707
707
  let setup: MockSetup;
708
708
 
709
709
  beforeEach(() => {
@@ -717,7 +717,7 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
717
717
  renameSessionExtension(setup.pi);
718
718
  });
719
719
 
720
- it("接线:callLLM ok:true + usage → pi.appendEntry 恰被调一次 ('rename-session', {model, usage}),且先于 setSessionName(标题照常落库)", async () => {
720
+ it("接线:callLLM ok:true + usage → pi.appendEntry 恰被调一次 ('rename-session', {model, usage}),且先于 setSessionName(标题照常写入)", async () => {
721
721
  vi.mocked(callLLM).mockResolvedValue({
722
722
  ok: true,
723
723
  content: "自动生成的标题",
@@ -732,13 +732,13 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
732
732
  model: "stub/stub-model",
733
733
  usage: STUB_USAGE,
734
734
  });
735
- // 时序:appendEntry 在 callRenameLLM 内(cleanTitle 前)触发,setSessionName 在其后 .then——落账先于落库
735
+ // 时序:appendEntry 在 callRenameLLM 内(cleanTitle 前)触发,setSessionName 在其后 .then——记录先于写入
736
736
  expect(setup.appendEntryMock.mock.invocationCallOrder[0]).toBeLessThan(
737
737
  setup.setSessionNameMock.mock.invocationCallOrder[0],
738
738
  );
739
739
  });
740
740
 
741
- it("catch 在回调体内:pi.appendEntry 抛错(session 已切换等)→ logger.error 且后续流程照常(标题照常落库)", async () => {
741
+ it("catch 在回调体内:pi.appendEntry 抛错(session 已切换等)→ logger.error 且后续流程照常(标题照常写入)", async () => {
742
742
  vi.mocked(callLLM).mockResolvedValue({
743
743
  ok: true,
744
744
  content: "自动生成的标题",
@@ -749,7 +749,7 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
749
749
  });
750
750
 
751
751
  await fire(setup, createMockCtx());
752
- // 「标题照常落库」:appendEntry 抛错被回调体内 catch,detached 链继续走 cleanTitle → setSessionName
752
+ // 「标题照常写入」:appendEntry 抛错被回调体内 catch,detached 链继续走 cleanTitle → setSessionName
753
753
  await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("自动生成的标题"));
754
754
 
755
755
  expect(loggerMock.error).toHaveBeenCalledWith("failed to append usage entry", {
@@ -762,7 +762,7 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
762
762
  expect(outerCatchCalls).toHaveLength(0);
763
763
  });
764
764
 
765
- it("usage 缺失 → appendEntry 不被调(存在性守卫),标题照常落库", async () => {
765
+ it("usage 缺失 → appendEntry 不被调(存在性检查),标题照常写入", async () => {
766
766
  vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "自动生成的标题" });
767
767
 
768
768
  await fire(setup, createMockCtx());
@@ -773,13 +773,14 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
773
773
  });
774
774
 
775
775
  // ────────────────────────────────────────────────────
776
- // rename_session 工具注册与 execute 守卫(load 求值时点 / agent-tool 模式)
776
+ // rename_session 工具注册与 execute 检查(load 求值时点 / agent-tool 模式)
777
777
  // ────────────────────────────────────────────────────
778
778
 
779
779
  /** 注册到的工具定义形态(registerTool 捕获后测试侧调用 execute 用)。 */
780
780
  interface RegisteredRenameTool {
781
781
  name: string;
782
782
  parameters: object;
783
+ renderResult: (result: unknown, options: { expanded: boolean }, theme: Theme) => { render: (width: number) => string[] };
783
784
  execute: (
784
785
  toolCallId: string,
785
786
  params: { title: string },
@@ -792,10 +793,10 @@ interface RegisteredRenameTool {
792
793
  }>;
793
794
  }
794
795
 
795
- describe("rename_session 工具注册与 execute 守卫(D3)", () => {
796
+ describe("rename_session 工具注册与 execute 检查(D3)", () => {
796
797
  let setup: MockSetup;
797
798
 
798
- /** 显式设置 load 时 mode 后重建工厂(注册面只在 load 求值一次)。 */
799
+ /** 显式设置 load 时 mode 后重建工厂(注册点只在 load 求值一次)。 */
799
800
  function setupWithMode(config: RenameSessionConfig): void {
800
801
  vi.mocked(loadRenameConfig).mockReset();
801
802
  vi.mocked(loadRenameConfig).mockReturnValue(config);
@@ -819,7 +820,22 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
819
820
  expect(tool.parameters).toHaveProperty("properties.title");
820
821
  });
821
822
 
822
- it("TC-T2: load 时 mode=first-prompt → 工具不注册(三值互斥,工具面 load 求值)", () => {
823
+ it("TC-T1b: renderResult 接线(C-ext-05):注册定义携带函数成员 + 折叠态输出 details.summary 一行", () => {
824
+ setupWithMode(AGENT_TOOL_CONFIG);
825
+ const tool = setup.registeredTool as RegisteredRenameTool;
826
+ expect(typeof tool.renderResult).toBe("function");
827
+ const renderTheme = { fg: (_token: string, text: string) => text } as unknown as Theme;
828
+ const result = {
829
+ content: [{ type: "text", text: "已命名:new-title" }],
830
+ details: { summary: 'Renamed session to "new-title"' },
831
+ };
832
+ // Text.render 按宽度 pad,trim 后断言(折叠态 = details.summary 一行)
833
+ const lines = tool.renderResult(result, { expanded: false }, renderTheme).render(400);
834
+ expect(lines.length).toBe(1);
835
+ expect(lines[0].trim()).toBe('Renamed session to "new-title"');
836
+ });
837
+
838
+ it("TC-T2: load 时 mode=first-prompt → 工具不注册(三值互斥,工具范围 load 求值)", () => {
823
839
  setupWithMode(FIRST_PROMPT_CONFIG);
824
840
  expect(setup.registerToolMock).not.toHaveBeenCalled();
825
841
  });
@@ -833,7 +849,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
833
849
  setupWithMode(AGENT_TOOL_CONFIG); // load 时注册
834
850
  const tool = setup.registeredTool as RegisteredRenameTool;
835
851
 
836
- // 运行中 GUI 切到 first-stop(live 读 config,残留工具被守卫兜底——V10 B 段)
852
+ // 运行中 GUI 切到 first-stop(live 读 config,残留工具被检查兜底——V10 B 段)
837
853
  vi.mocked(loadRenameConfig).mockReturnValue(FIRST_PROMPT_CONFIG);
838
854
 
839
855
  await expect(
@@ -864,7 +880,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
864
880
  expect(result.content[0].text).toBe("已命名:重构配置加载");
865
881
  });
866
882
 
867
- it("TC-T6: execute 时 title 清洗后为空(纯标点)→ isError(throw),不落库", async () => {
883
+ it("TC-T6: execute 时 title 清洗后为空(纯标点)→ isError(throw),不写入", async () => {
868
884
  setupWithMode(AGENT_TOOL_CONFIG);
869
885
  const tool = setup.registeredTool as RegisteredRenameTool;
870
886
 
@@ -874,7 +890,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
874
890
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
875
891
  });
876
892
 
877
- it("TC-T7: agent 显式改名允许覆盖既有名(不走 getSessionName 防覆盖守卫——语义等同 GUI 手动 rename,D3)", async () => {
893
+ it("TC-T7: agent 显式改名允许覆盖既有名(不走 getSessionName 防覆盖检查——语义等同 GUI 手动 rename,D3)", async () => {
878
894
  setupWithMode(AGENT_TOOL_CONFIG);
879
895
  const tool = setup.registeredTool as RegisteredRenameTool;
880
896
  // 既有自动名/语义名
@@ -885,11 +901,11 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
885
901
  expect(setup.setSessionNameMock).toHaveBeenCalledWith("agent 的新名字");
886
902
  });
887
903
 
888
- it("TC-T8: execute 时 subagent session 路径 → isError(throw 守卫,D7 三入口闭环 / C-ext-21)且不调 setSessionName;正常 session 路径不受影响", async () => {
904
+ it("TC-T8: execute 时 subagent session 路径 → isError(throw 检查,D7 三入口闭环 / C-ext-21)且不调 setSessionName;正常 session 路径不受影响", async () => {
889
905
  setupWithMode(AGENT_TOOL_CONFIG);
890
906
  const tool = setup.registeredTool as RegisteredRenameTool;
891
907
 
892
- // subagent 路径:throw 且不落库(文案只说结论与指引,不暴露 subagents 目录路径细节)
908
+ // subagent 路径:throw 且不写入(文案只说结论与指引,不暴露 subagents 目录路径细节)
893
909
  await expect(
894
910
  tool.execute(
895
911
  "call-1",
@@ -901,7 +917,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
901
917
  ).rejects.toThrow(/subagent.*不支持重命名/);
902
918
  expect(setup.setSessionNameMock).not.toHaveBeenCalled();
903
919
 
904
- // 对照:正常 session 路径(默认 sessionDir 不含 subagents 段)同一工具照常落库
920
+ // 对照:正常 session 路径(默认 sessionDir 不含 subagents 段)同一工具照常写入
905
921
  await tool.execute("call-2", { title: "正常标题" }, undefined, undefined, createMockCtx());
906
922
  expect(setup.setSessionNameMock).toHaveBeenCalledWith("正常标题");
907
923
  });
@@ -230,9 +230,19 @@ describe("isSubagentSession", () => {
230
230
  expect(isSubagentSession("/home/u/.pi/agent/subagents/--proj--/sessions")).toBe(true);
231
231
  });
232
232
 
233
+ it("LTC4b: 新布局(session-store-layout-inversion D2/D3)subagents 树内路径返回 true", () => {
234
+ expect(isSubagentSession("/home/u/.taiji/ext-sessions/root-sid-1/subagents/sessions")).toBe(true);
235
+ });
236
+
233
237
  it("LTC5: 主 session 路径返回 false", () => {
234
238
  expect(isSubagentSession("/home/u/.pi/agent/sessions")).toBe(false);
235
239
  });
240
+
241
+ it("LTC5b: 存储树内非 subagent 域目录不误判(btw / workflow / canvases 同树相邻域)", () => {
242
+ expect(isSubagentSession("/home/u/.taiji/ext-sessions/root-sid-1/btw")).toBe(false);
243
+ expect(isSubagentSession("/home/u/.taiji/ext-sessions/root-sid-1/workflow/runs")).toBe(false);
244
+ expect(isSubagentSession("/home/u/.taiji/ext-sessions/root-sid-1/canvases/c-1")).toBe(false);
245
+ });
236
246
  });
237
247
 
238
248
  // ────────────────────────────────────────────────────
@@ -274,10 +284,16 @@ describe("resolveTitlePrompt", () => {
274
284
  });
275
285
  expect(resolveTitlePrompt({ ...baseCfg, style: "sentence" }).instruction).toBe(BUILTIN_TEMPLATES.sentence);
276
286
  expect(resolveTitlePrompt({ ...baseCfg, style: "emoji" }).instruction).toBe(BUILTIN_TEMPLATES.emoji);
277
- // 非 custom 风格的 system 恒为 slug 版(词表与 slug 约束自洽)
287
+ // slug/sentence/emoji 的 system 为 slug 版(topic 例外走 GENERIC,见下)
278
288
  expect(resolveTitlePrompt({ ...baseCfg, style: "sentence" }).systemPrompt).toBe(RENAME_SYSTEM_PROMPT);
279
289
  });
280
290
 
291
+ it("topic → BUILTIN_TEMPLATES.topic + GENERIC_SYSTEM_PROMPT(词表含定时类完整短句例外,slug system 的「不要完整句子」会与之冲突)", () => {
292
+ const r = resolveTitlePrompt({ ...baseCfg, style: "topic" });
293
+ expect(r.instruction).toBe(BUILTIN_TEMPLATES.topic);
294
+ expect(r.systemPrompt).toBe(GENERIC_SYSTEM_PROMPT);
295
+ });
296
+
281
297
  it("custom 且指令非空 → 自定义指令 + GENERIC_SYSTEM_PROMPT(风格权威 = 用户指令)", () => {
282
298
  const r = resolveTitlePrompt({ ...baseCfg, style: "custom", customInstruction: "生成不超过 12 字的中文标题" });
283
299
  expect(r.instruction).toBe("生成不超过 12 字的中文标题");
@@ -735,7 +751,7 @@ describe("callRenameLLM", () => {
735
751
  });
736
752
 
737
753
  // ────────────────────────────────────────────────────
738
- // usage 落账回调(appendUsageEntry 注入;时点与 catch 归属契约见 llm.ts CallRenameLLMOptions)
754
+ // usage 记录回调(appendUsageEntry 注入;时点与 catch 归属契约见 llm.ts CallRenameLLMOptions)
739
755
  // ────────────────────────────────────────────────────
740
756
 
741
757
  /** 合法 Usage 夹具(pi-ai Usage 全必填字段,消除 unsafe-cast 强断言)。 */
@@ -748,7 +764,7 @@ const STUB_USAGE: Usage = {
748
764
  cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0, total: 0.03 },
749
765
  };
750
766
 
751
- describe("callRenameLLM usage 落账回调(appendUsageEntry 注入)", () => {
767
+ describe("callRenameLLM usage 记录回调(appendUsageEntry 注入)", () => {
752
768
  beforeEach(() => {
753
769
  vi.clearAllMocks();
754
770
  });
@@ -789,7 +805,7 @@ describe("callRenameLLM usage 落账回调(appendUsageEntry 注入)", () =>
789
805
  expect(appendUsageEntry).toHaveBeenCalledWith("stub/stub-model", STUB_USAGE);
790
806
  });
791
807
 
792
- it("usage 缺失(provider 不回)→ 跳过回调不落账(存在性守卫),标题照常返回", async () => {
808
+ it("usage 缺失(provider 不回)→ 跳过回调不记录(存在性检查),标题照常返回", async () => {
793
809
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
794
810
  vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "修复登录bug" });
795
811
  const appendUsageEntry = vi.fn();
@@ -821,7 +837,7 @@ describe("callRenameLLM usage 落账回调(appendUsageEntry 注入)", () =>
821
837
  expect(appendUsageEntry).toHaveBeenCalledTimes(1);
822
838
  });
823
839
 
824
- it("违约回调(实现未自吞错直接抛出)→ callRenameLLM reject(llm.ts 不吞错——catch 归属钉死回调实现内部,防双重 catch 漂移;真实接线的兜底由 landing.ts 回调体内 catch + 外层 .catch 覆盖)", async () => {
840
+ it("违约回调(实现未自吞错直接抛出)→ callRenameLLM reject(llm.ts 不吞错——catch 归属固定为回调实现内部,防双重 catch 漂移;真实接线的兜底由 landing.ts 回调体内 catch + 外层 .catch 覆盖)", async () => {
825
841
  vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
826
842
  vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "修复登录bug", usage: STUB_USAGE });
827
843
  const appendUsageEntry = vi.fn(() => {
@@ -116,7 +116,7 @@ describe("countUserMessages", () => {
116
116
  });
117
117
 
118
118
  // ────────────────────────────────────────────────────
119
- // cleanTitle(收口自旧 extractTitle,输入改为 string:callLLM 已 extractText+trim)
119
+ // cleanTitle(收敛自旧 extractTitle,输入改为 string:callLLM 已 extractText+trim)
120
120
  // ────────────────────────────────────────────────────
121
121
 
122
122
  describe("cleanTitle", () => {
@@ -351,8 +351,8 @@ describe("normalizeRenameConfig", () => {
351
351
 
352
352
  // ── style + customInstruction(rename-prompt-config 设计)──
353
353
 
354
- it("style 四个合法值(slug/sentence/emoji/custom)→ 原样保留", () => {
355
- for (const style of ["slug", "sentence", "emoji", "custom"] as const) {
354
+ it("style 五个合法值(slug/sentence/emoji/topic/custom)→ 原样保留", () => {
355
+ for (const style of ["slug", "sentence", "emoji", "topic", "custom"] as const) {
356
356
  expect(normalizeRenameConfig({ style }).style).toBe(style);
357
357
  }
358
358
  });
package/src/index.ts CHANGED
@@ -48,14 +48,14 @@ interface MessageEndLikeEvent {
48
48
  }
49
49
 
50
50
  /**
51
- * mode 切走后残留 rename_session 工具被调用的 execute 守卫文案(isError 经 throw 产生)。
52
- * 与失败路径表「守卫拒绝」行 / V10 通过标准三处对齐:含恢复动作指引。
51
+ * mode 切走后残留 rename_session 工具被调用的 execute 检查文案(isError 经 throw 产生)。
52
+ * 与失败路径表「检查拒绝」行 / V10 通过标准三处对齐:含恢复动作指引。
53
53
  */
54
54
  const RENAME_TOOL_MODE_GUARD_MESSAGE =
55
55
  "模式已切换,rename_session 仅在 agent-tool 模式可用;本会话可切回 agent-tool 立即恢复(live 读 mode),或手动改名(GUI rename / pi 原生 /name)。";
56
56
 
57
57
  /**
58
- * subagent session 的 rename_session 工具 execute 守卫文案(守卫闭环,C-ext-21 三入口
58
+ * subagent session 的 rename_session 工具 execute 检查文案(检查闭环,C-ext-21 三入口
59
59
  * 复用;isError 经 throw 产生)。不暴露内部路径细节——subagents 目录形态是跨包实现耦合,
60
60
  * 面向 agent 的报错只说结论与指引。
61
61
  */
@@ -66,17 +66,17 @@ const RENAME_TOOL_SUBAGENT_GUARD_MESSAGE =
66
66
  * 注册 rename_session 工具(agent-tool 模式)。
67
67
  *
68
68
  * 关键语义:
69
- * - subagent session 守卫:isSubagentSession 命中即 throw isError——subagent
69
+ * - subagent session 检查:isSubagentSession 命中即 throw isError——subagent
70
70
  * 子进程同样加载本 extension(全局 mode=agent-tool 时 subagent 也注册本工具),
71
71
  * 子会话是临时产物不参与 rename 体系,与 message_end / turn_end 两入口同语义。
72
- * - execute 内 live 读 config 守卫:mode !== "agent-tool"(mode 切走后本工具残留在
72
+ * - execute 内 live 读 config 检查:mode !== "agent-tool"(mode 切走后本工具残留在
73
73
  * 已存活 session 的工具清单——pi 无 unregisterTool)→ throw isError(pi 1.0.0 实装:
74
74
  * execute 抛出经 pi-agent-core agent-loop.js executePreparedToolCall 外层 catch 统一转
75
75
  * isError:true 的 error tool result〔:581-588 createErrorToolResult(message)〕;统一
76
76
  * throw 为纪律选择(单一错误路径),非「返回值 isError 失效」——1.0.0 起返回值上的
77
77
  * isError 标记同样被尊重〔:579〕)。
78
- * - cleanTitle 空值同样 throw isError(空白/纯标点标题不可落库)。
79
- * - 非空直接 pi.setSessionName,不走 getSessionName() 防覆盖守卫——agent 显式调用
78
+ * - cleanTitle 空值同样 throw isError(空白/纯标点标题不可写入)。
79
+ * - 非空直接 pi.setSessionName,不走 getSessionName() 防覆盖检查——agent 显式调用
80
80
  * 是「代表用户的意图」,语义等同 GUI 手动 rename,允许覆盖任何既有名(含自动名/语义名)。
81
81
  */
82
82
  function registerRenameSessionTool(pi: ExtensionAPI): void {
@@ -101,7 +101,7 @@ function registerRenameSessionTool(pi: ExtensionAPI): void {
101
101
  _onUpdate: undefined,
102
102
  ctx: ExtensionContext,
103
103
  ): Promise<AgentToolResult<RenameSessionDetails>> {
104
- // subagent session 守卫先于 mode 守卫:subagent 拒绝与 mode 无关(即便 mode 仍为
104
+ // subagent session 检查先于 mode 检查:subagent 拒绝与 mode 无关(即便 mode 仍为
105
105
  // agent-tool 也不该命名临时子会话),且文案归因到 subagent 而非误导性的「模式已切换」
106
106
  if (isSubagentSession(ctx.sessionManager.getSessionDir())) {
107
107
  throw new Error(RENAME_TOOL_SUBAGENT_GUARD_MESSAGE);
@@ -132,7 +132,7 @@ function registerRenameSessionTool(pi: ExtensionAPI): void {
132
132
 
133
133
  /**
134
134
  * pi-rename-session extension 工厂函数。
135
- * 三种触发模式(默认 first-stop)共用同一落库管道(callRenameLLM → 防覆盖重查 →
135
+ * 三种触发模式(默认 first-stop)共用同一写入管道(callRenameLLM → 防覆盖重查 →
136
136
  * setSessionName):
137
137
  * - first-stop(默认):首个成功 round 末触发(turn_end 入口,现状行为)
138
138
  * - first-prompt:首条 user 消息发出即触发(message_end 入口,不等回复,标题只基于 prompt)
@@ -146,9 +146,9 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
146
146
 
147
147
  registerAutoRenameCommand(pi);
148
148
 
149
- // 工具注册面(求值时点边界):只在 extension load 时求值一次——pi 无 unregisterTool,
149
+ // 工具注册点(求值时点边界):只在 extension load 时求值一次——pi 无 unregisterTool,
150
150
  // 切 mode 后已存活 session 的工具清单不回溯(切到 agent-tool 当前 session 无工具、切走则
151
- // 工具残留),残留工具由 execute 内 live mode 守卫兜底(RENAME_TOOL_MODE_GUARD_MESSAGE)。
151
+ // 工具残留),残留工具由 execute 内 live mode 检查兜底(RENAME_TOOL_MODE_GUARD_MESSAGE)。
152
152
  if (loadRenameConfig().mode === "agent-tool") {
153
153
  registerRenameSessionTool(pi);
154
154
  }
@@ -173,14 +173,14 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
173
173
  const config = loadRenameConfig();
174
174
  if (!config.enabled) return;
175
175
 
176
- // 3. mode 分派(事件面 live 读:GUI 切 mode 对活跃 session 的自动命名即时生效——
176
+ // 3. mode 分派(事件路径 live 读:GUI 切 mode 对活跃 session 的自动命名即时生效——
177
177
  // first-stop / agent-tool 模式下本入口静默返回,V10 A 段「切走即停」)
178
178
  if (config.mode !== "first-prompt") {
179
179
  debugLog(`skip: mode=${config.mode}`);
180
180
  return;
181
181
  }
182
182
 
183
- // 4. 排除 subagent 子进程 session(守卫链复用,C-ext-21 覆盖面含本新入口)
183
+ // 4. 排除 subagent 子进程 session(检查链复用,C-ext-21 覆盖范围含本新入口)
184
184
  if (isSubagentSession(ctx.sessionManager.getSessionDir())) return;
185
185
 
186
186
  // 5. 首条 user 判定:extension handler 先于本条 message 的 entries append 执行
@@ -215,7 +215,7 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
215
215
  // rename 是 best-effort,任何 LLM 失败(网络/提取/auth/model 不可用)都静默跳过保留原 label,
216
216
  // 不进 session history。
217
217
  } catch (e) {
218
- // best-effort 降级:message_end handler 同步部分(守卫判定)抛错时记录但不阻断
218
+ // best-effort 降级:message_end handler 同步部分(检查判定)抛错时记录但不阻断
219
219
  // 事件链——rename 是非关键副作用,任何失败不得干扰主对话。
220
220
  logger.error("failed", { error: String(e) });
221
221
  }
@@ -234,7 +234,7 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
234
234
  const config = loadRenameConfig();
235
235
  if (!config.enabled) return;
236
236
 
237
- // 2. mode 分派(事件面 live 读):first-stop 才走 turn_end 自动命名——
237
+ // 2. mode 分派(事件路径 live 读):first-stop 才走 turn_end 自动命名——
238
238
  // first-prompt / agent-tool 模式下本入口静默返回(V10 A 段「切走即停」;
239
239
  // 切回 first-stop 后 live 恢复分派,但已跑过成功 round 的 session 受一次性窗口约束)
240
240
  if (config.mode !== "first-stop") {
@@ -261,11 +261,11 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
261
261
  return;
262
262
  }
263
263
 
264
- // 6. LLM 生成标题并落库。pi 运行时的事件链是 await 的(runner.emit → await handler),
264
+ // 6. LLM 生成标题并写入。pi 运行时的事件链是 await 的(runner.emit → await handler),
265
265
  // 若 await callRenameLLM 会阻塞 agent 进入下一次迭代。这里用 detached promise 脱离 await 链,
266
266
  // 真正实现 fire-and-forget:handler 立即 resolve,LLM 调用与 setSessionName 在后台异步完成。
267
- // 落库管道(起 LLM → 防覆盖重查 → setSessionName → renamed to 日志)见 landing.ts:
268
- // 与 first-prompt 入口共用同一段,usage 落账回调 / skip / renamed to 契约由该处单点保证
267
+ // 写入管道(起 LLM → 防覆盖重查 → setSessionName → renamed to 日志)见 landing.ts:
268
+ // 与 first-prompt 入口共用同一段,usage 记录回调 / skip / renamed to 契约由该处单点保证
269
269
  void landTitle(pi, ctx, config, event.message, { debugLog });
270
270
  // rename 是 best-effort,任何 LLM 失败(网络/提取/auth/model 不可用)都静默跳过保留原 label,
271
271
  // 不进 session history。
package/src/landing.ts CHANGED
@@ -10,8 +10,8 @@ const logger = getLogger("rename-session");
10
10
  * landTitle 注入项。
11
11
  *
12
12
  * llm.ts 刻意不依赖 pi 句柄(ExtensionAPI,见该模块 CallRenameLLMOptions 注释)——
13
- * 自动命名的 pi 副作用(getSessionName 防覆盖重查 / setSessionName 落库 / appendEntry
14
- * 落账)与本包唯一的自动落库管道收口在本模块,两触发入口不再各自复写这段。
13
+ * 自动命名的 pi 副作用(getSessionName 防覆盖重查 / setSessionName 写入 / appendEntry
14
+ * 记录)与本包唯一的自动写入管道收敛在本模块,两触发入口不再各自复写这段。
15
15
  */
16
16
  export interface LandTitleOptions {
17
17
  /**
@@ -31,18 +31,18 @@ export interface LandTitleOptions {
31
31
  }
32
32
 
33
33
  /**
34
- * 三种触发模式的共用落库管道:起 LLM → 防覆盖重查 → setSessionName → `renamed to` 日志。
34
+ * 三种触发模式的共用写入管道:起 LLM → 防覆盖重查 → setSessionName → `renamed to` 日志。
35
35
  *
36
- * 关键契约(两触发入口一度逐字重复,收口于此——改一处即两入口同步):
37
- * - usage 落账:闭包捕获 pi,把 rename LLM 调用的 usage 以 custom entry 落盘
36
+ * 关键契约(两触发入口一度逐字重复,收敛于此——改一处即两入口同步):
37
+ * - usage 记录:闭包捕获 pi,把 rename LLM 调用的 usage 以 custom entry 落盘
38
38
  * (pi.appendEntry → {type:"custom",customType:"rename-session",data:{model,usage},timestamp},
39
39
  * 不进对话流不进 LLM 上下文)。调用时点(ok:true && usage 后立即、cleanTitle 前)由
40
40
  * llm.ts 统一规定;catch 必须位于回调实现内部——appendEntry 抛错(session 已切换等)
41
- * 只记日志,不影响回调返回与后续 cleanTitle/setSessionName(「标题照常落库」)。
42
- * - 防覆盖:落库前重查——LLM 调用窗口(2-30s)内语义名/手动命名的竞态由此兜住
41
+ * 只记日志,不影响回调返回与后续 cleanTitle/setSessionName(「标题照常写入」)。
42
+ * - 防覆盖:写入前重查——LLM 调用窗口(2-30s)内语义名/手动命名的竞态由此兜住
43
43
  * (发起前查没有意义,那时查不能防竞态;skip 文案是 E2E 硬契约)。
44
44
  * - 日志时点:`renamed to` 必须晚于 setSessionName——防覆盖 return 在前,竞态命中时本
45
- * 日志不出现,避免「日志称 renamed 但未落库」。
45
+ * 日志不出现,避免「日志称 renamed 但未写入」。
46
46
  * - 错误传播:整条链路 catch 自吞(logger.error「rename LLM failed」),返回的 promise
47
47
  * 永不 reject;调用方按 fire-and-forget 处理(`void landTitle(...)`)。rename 是
48
48
  * best-effort,任何 LLM 失败(网络/提取/auth/model 不可用)都静默跳过保留原 label,
@@ -76,7 +76,7 @@ export function landTitle(
76
76
  return;
77
77
  }
78
78
  pi.setSessionName(title);
79
- // 落库成功才打「renamed to」(防覆盖 return 在前,竞态命中时本日志不出现)
79
+ // 写入成功才打「renamed to」(防覆盖 return 在前,竞态命中时本日志不出现)
80
80
  opts.debugLog(`renamed to "${title}"`);
81
81
  })
82
82
  .catch((e) => logger.error("rename LLM failed", { error: String(e) }));
package/src/llm.ts CHANGED
@@ -40,6 +40,16 @@ export const RENAME_INSTRUCTION =
40
40
  const SENTENCE_INSTRUCTION =
41
41
  "根据以上对话,为这个会话生成一个简短标题:一个不超过 20 字的完整短句,概括本次任务的目标或结果,不加句尾标点。反例(错误):「重构」。使用对话所用的语言。只输出标题文本。";
42
42
 
43
+ /**
44
+ * 「主题标签」风格内置词表(rename-prompt-config 设计):主题标签 = 「对象 + 动作」短词组
45
+ * (如「对话投递重构」「provider统一」),代码符号保留英文原词;定时/等待类任务例外用
46
+ * 「触发条件 + 动作」完整短句。正反例取自用户真实重命名样本(风格锚定,与
47
+ * RENAME_INSTRUCTION 同范式)。仅 BUILTIN_TEMPLATES 消费(renderer 侧是跨包字面量
48
+ * 对拍测试,不 import 本模块)。
49
+ */
50
+ const TOPIC_INSTRUCTION =
51
+ "为会话生成一个「主题标签」式标题——像代码库里的分支名或任务卡名,不像一句话。\n默认形态:对象 + 动作(修复、重构、优化、简化、清理、设计、审查、适配、统一),4~15 个字。\n- 会话围绕具体模块、工具、技能或文件时,标题里直接用它的原词(英文原样保留),例:「provider统一」「composer icon」「pr-cr-fix」\n- 主题本身就是单一功能点时,裸名词即可,例:「托盘」「快捷键」「changeset」\n例外:定时或等待类任务(定时执行、等某条件满足后继续、额度恢复后重试),标题写成「触发条件 + 动作」的完整短句,例:「18:46 额度恢复后继续 dev-flow 开发」「每30分钟检查两会话完成后执行 pr-cr-fix」\n禁止:完整叙述句(如「我帮你修复了登录 bug」)、超长任务描述(如「分析侧边栏会话重命名失败的问题并给出修复方案」)、时态与结果表述(已修复X、完成了X)、emoji、句尾标点。\n用对话所用的语言写,代码符号保留原词。只输出标题文本。";
52
+
43
53
  /**
44
54
  * 「Emoji 点缀」风格内置词表(rename-prompt-config 设计):以贴切 emoji 开头 + 不超过 15 字
45
55
  * 词组或短句。正例锚定输出形态,避免模型漏掉 emoji 或输出多行。
@@ -64,11 +74,15 @@ export const BUILTIN_TEMPLATES: Readonly<Record<Exclude<RenameStyle, "custom">,
64
74
  slug: RENAME_INSTRUCTION,
65
75
  sentence: SENTENCE_INSTRUCTION,
66
76
  emoji: EMOJI_INSTRUCTION,
77
+ topic: TOPIC_INSTRUCTION,
67
78
  };
68
79
 
69
80
  /**
70
81
  * 按配置解析生效的标题提示词(rename-prompt-config 设计核心收敛点):
71
82
  * - 非 custom 枚举风格 → BUILTIN_TEMPLATES 词表 + RENAME_SYSTEM_PROMPT(slug 约束与词表自洽)
83
+ * - topic 例外 → 词表 + GENERIC_SYSTEM_PROMPT(topic 词表自带完整风格约束,且含定时类
84
+ * 「完整短句」例外正例,与 RENAME_SYSTEM_PROMPT 的「不要完整句子」直接冲突——风格
85
+ * 权威 = 词表指令,system 降级为输出契约骨架,与 custom 路径同理由)
72
86
  * - custom 且自定义指令非空 → 自定义指令 + GENERIC_SYSTEM_PROMPT(风格权威 = 用户指令,
73
87
  * system 降级为输出契约骨架,消解双处风格冲突)
74
88
  * - custom 但自定义指令为空(含全空白)→ 回落 slug 默认(与「空 = 内置默认」语义一致)
@@ -80,6 +94,9 @@ export function resolveTitlePrompt(config: RenameSessionConfig): { systemPrompt:
80
94
  // custom 但自定义指令为空(含全空白)→ 回落 slug 默认(「空 = 内置默认」语义);
81
95
  // 其余枚举 → 词表直查(Record 键完备,无 fallback 分支)
82
96
  const style: Exclude<RenameStyle, "custom"> = config.style === "custom" ? "slug" : config.style;
97
+ if (style === "topic") {
98
+ return { systemPrompt: GENERIC_SYSTEM_PROMPT, instruction: BUILTIN_TEMPLATES.topic };
99
+ }
83
100
  return { systemPrompt: RENAME_SYSTEM_PROMPT, instruction: BUILTIN_TEMPLATES[style] };
84
101
  }
85
102
 
@@ -89,11 +106,16 @@ export function resolveTitlePrompt(config: RenameSessionConfig): { systemPrompt:
89
106
  * sessionDir 路径含 subagents 段 → 是 subagent 子进程 session,跳过 rename。
90
107
  *
91
108
  * 跨包路径耦合(constraints.json C-ext-21):`subagents` 目录布局由 taiji 的
92
- * `packages/subagent-core/src/execution/assembly/path-encoding.ts`(getSubagentSessionDir /
93
- * encodeCwd)定义并写入。本包 role=universal,不 import subagent-core
94
- * (import 会把 universal 包绑死在 taiji 体系上),只按路径形态守卫——
95
- * path-encoding.ts 侧有对向注释互指。改 subagent 目录布局必须双侧同步改。
96
- * 三模式的新入口(message_end / rename_session 工具)同样复用本守卫。
109
+ * `packages/subagent-core/src/execution/assembly/path-encoding.ts`(getSubagentSessionDir)
110
+ * 定义并写入;[session-store-layout-inversion D2/D3] 布局 = `<dataDir>/ext-sessions/
111
+ * <rootSessionId>/subagents/sessions/`(rootSessionId 树内分区,旧 encodeCwd 层消除)。
112
+ * 嗅探规则「路径含 `<sep>subagents<sep>` 段」对新旧布局同构命中,无需形态分支;
113
+ * 防误判:普通主会话(`<agentDir>/sessions/<encodeCwd>/`)与存储树内其他域目录
114
+ * (btw / workflow / canvases 等)均不含 `subagents` 段,不会误判为 subagent 会话。
115
+ * 本包 role=universal,不 import subagent-core(import 会把 universal 包绑死在
116
+ * taiji 体系上),只按路径形态守卫——path-encoding.ts 侧有对向注释互指。
117
+ * 改 subagent 目录布局必须双侧同步改。三模式的新入口(message_end /
118
+ * rename_session 工具)同样复用本守卫。
97
119
  */
98
120
  export function isSubagentSession(sessionDir: string): boolean {
99
121
  return sessionDir.includes(path.sep + "subagents" + path.sep);
package/src/pure.ts CHANGED
@@ -18,11 +18,11 @@ import {
18
18
  export type RenameMode = "first-prompt" | "first-stop" | "agent-tool";
19
19
 
20
20
  /**
21
- * 标题提示词风格枚举(设计 rename-prompt-config):slug/sentence/emoji = 内置词表(随 extension
21
+ * 标题提示词风格枚举(设计 rename-prompt-config):slug/sentence/emoji/topic = 内置词表(随 extension
22
22
  * 版本升级自动跟随),custom = 用户自定义自由文本(customInstruction 承载,空串回落内置 slug)。
23
23
  * 与 @taiji/shared RenameStyle 值域同构(跨包不 import,本包 role=universal)。
24
24
  */
25
- export type RenameStyle = "slug" | "sentence" | "emoji" | "custom";
25
+ export type RenameStyle = "slug" | "sentence" | "emoji" | "topic" | "custom";
26
26
 
27
27
  /**
28
28
  * 自定义标题提示词长度上限(Unicode 码点数)——与 @taiji/shared RENAME_INSTRUCTION_MAX_LENGTH
@@ -34,7 +34,7 @@ export const RENAME_INSTRUCTION_MAX_LENGTH = 2000;
34
34
  /**
35
35
  * rename-session 配置 schema(落盘到 `<agentDir>/config/rename-session-ext-config.json`,路径由 llm-shared 推导)。
36
36
  *
37
- * 收口自旧版 pure.ts 的 `RenameConfig`(switchFilePath/maxTitleLength/renameInstruction 硬编码常量):
37
+ * 收敛自旧版 pure.ts 的 `RenameConfig`(switchFilePath/maxTitleLength/renameInstruction 硬编码常量):
38
38
  * - 开关双机制:`enabled` 字段(pi CLI 用户主开关,默认 false)+ taiji runtime 的
39
39
  * auto-rename-enabled flag 文件(live 覆盖源,存在即开,见下方 [COMPAT] 契约)
40
40
  * - model 从「搭便车 ctx.model」改为独立 `ModelSelector`(仅支持 ref 精确指定;空 ref 跟随会话主模型,见 llm.ts 的空 ref fallback)
@@ -77,7 +77,13 @@ export interface RenameSessionConfig {
77
77
  const RENAME_MODES: ReadonlySet<string> = new Set(["first-prompt", "first-stop", "agent-tool"]);
78
78
 
79
79
  /** 合法提示词风格清单(与 RenameStyle 一致;normalize 校验用)。 */
80
- const RENAME_STYLES: ReadonlySet<string> = new Set(["slug", "sentence", "emoji", "custom"]);
80
+ const RENAME_STYLES: ReadonlySet<string> = new Set([
81
+ "slug",
82
+ "sentence",
83
+ "emoji",
84
+ "topic",
85
+ "custom",
86
+ ]);
81
87
 
82
88
  /**
83
89
  * 类型谓词:unknown 是否为合法触发模式(normalizeRenameConfig 校验用,单点断言)。
@@ -175,7 +181,7 @@ export function normalizeRenameConfig(raw: unknown): RenameSessionConfig {
175
181
  ? obj.maxTitleLength
176
182
  : DEFAULT_RENAME_CONFIG.maxTitleLength;
177
183
 
178
- // normalizeModelSelector:llm-shared 导出(ext-simplify-17 D6 收口)
184
+ // normalizeModelSelector:llm-shared 导出(ext-simplify-17 D6 收敛)
179
185
  const model = normalizeModelSelector(obj.model) ?? DEFAULT_RENAME_CONFIG.model;
180
186
 
181
187
  const thinkingLevel = normalizeThinkingLevel(obj.thinkingLevel) ?? DEFAULT_RENAME_CONFIG.thinkingLevel;
@@ -276,7 +282,7 @@ export function countSuccessfulAssistantReplies(entries: ReadonlyArray<EntryLike
276
282
  // ──────────────────────── 标题清洗 ────────────────────────
277
283
 
278
284
  /**
279
- * 按 Unicode 码点截断(ext-simplify-17 D13:收口包内三处同构 `Array.from` 骨架——
285
+ * 按 Unicode 码点截断(ext-simplify-17 D13:收敛包内三处同构 `Array.from` 骨架——
280
286
  * cleanTitle / truncateForTitle / previewText,三处差异全部参数化)。
281
287
  *
282
288
  * Array.from 按码点切分,星面字符(emoji 等,占 2 个 UTF-16 码元)不会被劈成半个代理对。
@@ -310,14 +316,14 @@ export function truncateCodePoints(
310
316
  *
311
317
  * 输入是 callLLM 已 extractText+trim 的 string(llm-shared/call.ts 的 extractText 负责从
312
318
  * AssistantMessage.content 提取 text block 拼接并 trim)。本函数只做 rename 特有的包装清理,
313
- * 是旧版 extractTitle(从 resp.content 提取)的收口后形态。
319
+ * 是旧版 extractTitle(从 resp.content 提取)的收敛后形态。
314
320
  */
315
321
  export function cleanTitle(content: string, maxLength: number): string {
316
322
  const trimmed = content.trim();
317
323
  if (!trimmed) return "";
318
324
 
319
325
  // B1: 归一化内部空白——把所有连续空白(含 \n / \r / \t)压成单空格,
320
- // 避免 LLM 返回多行标题(如 "重构API层\n更新文档")原样落库破坏 UI 标题/列表渲染
326
+ // 避免 LLM 返回多行标题(如 "重构API层\n更新文档")原样写入破坏 UI 标题/列表渲染
321
327
  const normalized = trimmed.replace(/\s+/g, " ");
322
328
 
323
329
  // 去首部引号/markdown 标记 + 尾部引号/markdown/标点(。..,,、;;!!??::)。
package/src/render.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
9
  import { Text } from "@earendil-works/pi-tui";
10
- import { firstContentText } from "@zhushanwen/extension-protocol";
10
+ import { readDetailsSummary, resultContentText } from "@zhushanwen/extension-protocol";
11
11
 
12
12
  /** rename_session 工具 result 的 details 形状(summary 由 buildRenameSessionSummary 产出)。 */
13
13
  export interface RenameSessionDetails { // oe-exempt:20261017:framework:details 数据形状窄 view(单实现消费,非抽象接口)
@@ -19,25 +19,6 @@ export function buildRenameSessionSummary(params: { title: string }): string {
19
19
  return `Renamed session to "${params.title}"`;
20
20
  }
21
21
 
22
- /** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
23
- function fieldValue(v: unknown, key: string): unknown {
24
- if (typeof v !== "object" || v === null) return undefined;
25
- return (v as Record<string, unknown>)[key];
26
- }
27
-
28
- /** 从 tool result 的 details 安全读取 summary(容错历史 entry 与非对象形态)。 */
29
- function readDetailsSummary(result: unknown): string | undefined {
30
- const summary = fieldValue(fieldValue(result, "details"), "summary");
31
- return typeof summary === "string" && summary !== "" ? summary : undefined;
32
- }
33
-
34
- /** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
35
- function contentText(result: unknown): string {
36
- const content = fieldValue(result, "content");
37
- if (!Array.isArray(content)) return "";
38
- return firstContentText({ content });
39
- }
40
-
41
22
  /**
42
23
  * TUI renderResult:折叠态(expanded=false)= details.summary 一行(dim);展开态 =
43
24
  * content 纯文本。summary 缺失(旧版本持久化 entry)时折叠态回退 content 文本。
@@ -48,7 +29,7 @@ export function renderRenameSessionResult(
48
29
  theme: Theme,
49
30
  ): Text {
50
31
  const summary = readDetailsSummary(result);
51
- const collapsed = summary ?? (contentText(result) || "Renamed");
52
- const text = options.expanded ? contentText(result) : collapsed;
32
+ const collapsed = summary ?? (resultContentText(result) || "Renamed");
33
+ const text = options.expanded ? resultContentText(result) : collapsed;
53
34
  return new Text(theme.fg("dim", text), 0, 0);
54
35
  }