@zhushanwen/pi-rename-session 0.13.0 → 0.14.0
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 +18 -18
- package/package.json +3 -3
- package/skills/rename-session-ext-config/SKILL.md +3 -3
- package/src/__tests__/index.test.ts +39 -39
- package/src/__tests__/llm.test.ts +21 -5
- package/src/__tests__/pure.test.ts +3 -3
- package/src/index.ts +18 -18
- package/src/landing.ts +9 -9
- package/src/llm.ts +27 -5
- package/src/pure.ts +14 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zhushanwen/pi-rename-session
|
|
2
2
|
|
|
3
|
-
Pi rename-session 扩展 — 三种触发模式下自动/自主为会话生成 slug
|
|
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
|
|
15
|
-
- 标题直接 `setSessionName`
|
|
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`
|
|
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
|
-
|
|
80
|
+
三个入口共用同一条写入管道(`callRenameLLM` → 防覆盖重查 → `setSessionName`),按 `mode` 分派:
|
|
81
81
|
|
|
82
|
-
1.
|
|
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
|
|
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.
|
|
89
|
-
8. **`rename_session` 工具(agent-tool)**:execute
|
|
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
|
|
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
|
-
|
|
|
105
|
-
| `rename_session` 工具被调用但 mode 已切走 | execute
|
|
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 "..."`) |
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.14.0",
|
|
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.
|
|
38
|
+
"@zhushanwen/extension-protocol": "0.23.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.
|
|
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
|
|
34
|
-
- `rename_session`
|
|
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` 回落空串(粒度容错,坏字段不影响其他字段)
|
|
@@ -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
|
|
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
|
-
//
|
|
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 →
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 →
|
|
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
|
|
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
|
|
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
|
|
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
|
|
588
|
+
// TC3 防覆盖:写入前重查(含 LLM 调用窗口竞态)
|
|
589
589
|
// ────────────────────────────────────────────────────
|
|
590
590
|
|
|
591
|
-
it("TC3: LLM 调用窗口内手动命名 →
|
|
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 →
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
765
|
+
it("usage 缺失 → appendEntry 不被调(存在性检查),标题照常写入", async () => {
|
|
766
766
|
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "自动生成的标题" });
|
|
767
767
|
|
|
768
768
|
await fire(setup, createMockCtx());
|
|
@@ -773,7 +773,7 @@ describe("usage 落账接线(appendUsageEntry 回调注入)", () => {
|
|
|
773
773
|
});
|
|
774
774
|
|
|
775
775
|
// ────────────────────────────────────────────────────
|
|
776
|
-
// rename_session 工具注册与 execute
|
|
776
|
+
// rename_session 工具注册与 execute 检查(load 求值时点 / agent-tool 模式)
|
|
777
777
|
// ────────────────────────────────────────────────────
|
|
778
778
|
|
|
779
779
|
/** 注册到的工具定义形态(registerTool 捕获后测试侧调用 execute 用)。 */
|
|
@@ -792,10 +792,10 @@ interface RegisteredRenameTool {
|
|
|
792
792
|
}>;
|
|
793
793
|
}
|
|
794
794
|
|
|
795
|
-
describe("rename_session 工具注册与 execute
|
|
795
|
+
describe("rename_session 工具注册与 execute 检查(D3)", () => {
|
|
796
796
|
let setup: MockSetup;
|
|
797
797
|
|
|
798
|
-
/** 显式设置 load 时 mode
|
|
798
|
+
/** 显式设置 load 时 mode 后重建工厂(注册点只在 load 求值一次)。 */
|
|
799
799
|
function setupWithMode(config: RenameSessionConfig): void {
|
|
800
800
|
vi.mocked(loadRenameConfig).mockReset();
|
|
801
801
|
vi.mocked(loadRenameConfig).mockReturnValue(config);
|
|
@@ -819,7 +819,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
819
819
|
expect(tool.parameters).toHaveProperty("properties.title");
|
|
820
820
|
});
|
|
821
821
|
|
|
822
|
-
it("TC-T2: load 时 mode=first-prompt →
|
|
822
|
+
it("TC-T2: load 时 mode=first-prompt → 工具不注册(三值互斥,工具范围 load 求值)", () => {
|
|
823
823
|
setupWithMode(FIRST_PROMPT_CONFIG);
|
|
824
824
|
expect(setup.registerToolMock).not.toHaveBeenCalled();
|
|
825
825
|
});
|
|
@@ -833,7 +833,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
833
833
|
setupWithMode(AGENT_TOOL_CONFIG); // load 时注册
|
|
834
834
|
const tool = setup.registeredTool as RegisteredRenameTool;
|
|
835
835
|
|
|
836
|
-
// 运行中 GUI 切到 first-stop(live 读 config
|
|
836
|
+
// 运行中 GUI 切到 first-stop(live 读 config,残留工具被检查兜底——V10 B 段)
|
|
837
837
|
vi.mocked(loadRenameConfig).mockReturnValue(FIRST_PROMPT_CONFIG);
|
|
838
838
|
|
|
839
839
|
await expect(
|
|
@@ -864,7 +864,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
864
864
|
expect(result.content[0].text).toBe("已命名:重构配置加载");
|
|
865
865
|
});
|
|
866
866
|
|
|
867
|
-
it("TC-T6: execute 时 title 清洗后为空(纯标点)→ isError(throw
|
|
867
|
+
it("TC-T6: execute 时 title 清洗后为空(纯标点)→ isError(throw),不写入", async () => {
|
|
868
868
|
setupWithMode(AGENT_TOOL_CONFIG);
|
|
869
869
|
const tool = setup.registeredTool as RegisteredRenameTool;
|
|
870
870
|
|
|
@@ -874,7 +874,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
874
874
|
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
875
875
|
});
|
|
876
876
|
|
|
877
|
-
it("TC-T7: agent 显式改名允许覆盖既有名(不走 getSessionName
|
|
877
|
+
it("TC-T7: agent 显式改名允许覆盖既有名(不走 getSessionName 防覆盖检查——语义等同 GUI 手动 rename,D3)", async () => {
|
|
878
878
|
setupWithMode(AGENT_TOOL_CONFIG);
|
|
879
879
|
const tool = setup.registeredTool as RegisteredRenameTool;
|
|
880
880
|
// 既有自动名/语义名
|
|
@@ -885,11 +885,11 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
885
885
|
expect(setup.setSessionNameMock).toHaveBeenCalledWith("agent 的新名字");
|
|
886
886
|
});
|
|
887
887
|
|
|
888
|
-
it("TC-T8: execute 时 subagent session 路径 → isError(throw
|
|
888
|
+
it("TC-T8: execute 时 subagent session 路径 → isError(throw 检查,D7 三入口闭环 / C-ext-21)且不调 setSessionName;正常 session 路径不受影响", async () => {
|
|
889
889
|
setupWithMode(AGENT_TOOL_CONFIG);
|
|
890
890
|
const tool = setup.registeredTool as RegisteredRenameTool;
|
|
891
891
|
|
|
892
|
-
// subagent 路径:throw
|
|
892
|
+
// subagent 路径:throw 且不写入(文案只说结论与指引,不暴露 subagents 目录路径细节)
|
|
893
893
|
await expect(
|
|
894
894
|
tool.execute(
|
|
895
895
|
"call-1",
|
|
@@ -901,7 +901,7 @@ describe("rename_session 工具注册与 execute 守卫(D3)", () => {
|
|
|
901
901
|
).rejects.toThrow(/subagent.*不支持重命名/);
|
|
902
902
|
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
903
903
|
|
|
904
|
-
// 对照:正常 session 路径(默认 sessionDir 不含 subagents
|
|
904
|
+
// 对照:正常 session 路径(默认 sessionDir 不含 subagents 段)同一工具照常写入
|
|
905
905
|
await tool.execute("call-2", { title: "正常标题" }, undefined, undefined, createMockCtx());
|
|
906
906
|
expect(setup.setSessionNameMock).toHaveBeenCalledWith("正常标题");
|
|
907
907
|
});
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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 不回)→
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
-
//
|
|
149
|
+
// 工具注册点(求值时点边界):只在 extension load 时求值一次——pi 无 unregisterTool,
|
|
150
150
|
// 切 mode 后已存活 session 的工具清单不回溯(切到 agent-tool 当前 session 无工具、切走则
|
|
151
|
-
// 工具残留),残留工具由 execute 内 live mode
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
268
|
-
// 与 first-prompt 入口共用同一段,usage
|
|
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
|
|
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
|
-
*
|
|
34
|
+
* 三种触发模式的共用写入管道:起 LLM → 防覆盖重查 → setSessionName → `renamed to` 日志。
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
* - usage
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
*
|
|
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([
|
|
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
|
|
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更新文档"
|
|
326
|
+
// 避免 LLM 返回多行标题(如 "重构API层\n更新文档")原样写入破坏 UI 标题/列表渲染
|
|
321
327
|
const normalized = trimmed.replace(/\s+/g, " ");
|
|
322
328
|
|
|
323
329
|
// 去首部引号/markdown 标记 + 尾部引号/markdown/标点(。..,,、;;!!??::)。
|