@zhushanwen/pi-rename-session 0.9.4 → 0.10.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 +2 -2
- package/package.json +3 -3
- package/src/__tests__/index.test.ts +7 -3
- package/src/__tests__/llm.test.ts +39 -5
- package/src/__tests__/pure.test.ts +4 -2
- package/src/llm.ts +31 -5
- package/src/pure.ts +4 -7
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ rename 是 best-effort 副作用,任何失败静默跳过、绝不阻断 agent
|
|
|
115
115
|
| 11 | `skip: empty prompt` | message_end handler(`firstPrompt skip: empty prompt`) | user message 载荷无文本(理论不发生) |
|
|
116
116
|
| 12 | `tool renamed to "<title>"` | rename_session 工具 execute | agent-tool 路径落库成功(`setSessionName` 之后打出,无防覆盖前缀) |
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
另有三条**非 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 文件(无需复跑)。
|
|
119
119
|
|
|
120
120
|
## E2E 验收
|
|
121
121
|
|
|
@@ -131,7 +131,7 @@ node e2e/run-all.mjs # 顺序全跑:单场景失败不阻断后续,汇总
|
|
|
131
131
|
- harness API 与断言纯函数(单测 `e2e/harness.test.mjs` 随 vitest 跑):`e2e/harness.mjs`
|
|
132
132
|
- A2 的标题记录与人工抽查表(词组形态/语义相关/语言跟随三列):`e2e/RESULTS.md`(run-a2 自动追加,人工填写)
|
|
133
133
|
- 保留现场调试:`E2E_KEEP_TMP=1 node e2e/run-all.mjs`
|
|
134
|
-
-
|
|
134
|
+
- 测试模型缺省 `xiaomi-token-plan-cn/mimo-v2.6-flash`(`E2E_MODEL` 环境变量可覆盖,禁 kimi)
|
|
135
135
|
|
|
136
136
|
## 子 session 自动排除
|
|
137
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-rename-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"pi-package"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
36
|
+
"@zhushanwen/pi-ext-guards": "0.4.2",
|
|
37
37
|
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
38
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
38
|
+
"@zhushanwen/pi-llm-shared": "0.11.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -673,14 +673,18 @@ describe("renameSessionExtension", () => {
|
|
|
673
673
|
for (const lit of debugLiterals) {
|
|
674
674
|
expect(warnText(warnSpy)).not.toContain(lit);
|
|
675
675
|
}
|
|
676
|
-
//
|
|
677
|
-
//
|
|
676
|
+
// 「零调用」的精确口径:剔除常开 A1 日志(model not available / call failed /
|
|
677
|
+
// title empty——三者为设计内的无条件 warn,非 debug 契约;本用例组 2 空内容
|
|
678
|
+
// 触发 title empty 属预期留痕)后,其余 warn 调用必须为 0。
|
|
678
679
|
// 成功路径的 rename with model 已改为 debug-only,debug 关闭时同样不得出现。
|
|
679
680
|
const nonA1Calls = warnLines(warnSpy).filter(
|
|
680
681
|
(l) =>
|
|
681
682
|
!l.includes("model not available") &&
|
|
682
|
-
!l.includes("rename LLM call failed")
|
|
683
|
+
!l.includes("rename LLM call failed") &&
|
|
684
|
+
!l.includes("title empty after clean"),
|
|
683
685
|
);
|
|
686
|
+
// 组 2 的空内容路径恰好触发一次 title empty warn(常开留痕的存在性锚点)
|
|
687
|
+
expect(warnLines(warnSpy).filter((l) => l.includes("title empty after clean"))).toHaveLength(1);
|
|
684
688
|
expect(nonA1Calls).toHaveLength(0);
|
|
685
689
|
});
|
|
686
690
|
});
|
|
@@ -380,7 +380,9 @@ const STUB_MODEL: Model<Api> = {
|
|
|
380
380
|
api: "anthropic-messages",
|
|
381
381
|
provider: "stub",
|
|
382
382
|
baseUrl: "https://stub.invalid",
|
|
383
|
-
reasoning
|
|
383
|
+
// reasoning=true:档位按模型 supportedLevels 数据驱动判定(pi-ai getSupportedThinkingLevels),
|
|
384
|
+
// 不支持思考的模型只支持 "off"——用例要断言档位透传,故此处声明支持。
|
|
385
|
+
reasoning: true,
|
|
384
386
|
input: ["text"],
|
|
385
387
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
386
388
|
contextWindow: 128_000,
|
|
@@ -444,14 +446,30 @@ describe("callRenameLLM", () => {
|
|
|
444
446
|
expect(result).toBe("修复登录bug");
|
|
445
447
|
});
|
|
446
448
|
|
|
447
|
-
it("callLLM 返回空 content(cleanTitle 空串)→ 返回 null", async () => {
|
|
449
|
+
it("callLLM 返回空 content(cleanTitle 空串)→ 返回 null + logger.warn 留痕(stopReason/raw 结构化)", async () => {
|
|
448
450
|
vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
|
|
449
|
-
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: " " });
|
|
451
|
+
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: " ", stopReason: "length" });
|
|
452
|
+
const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE);
|
|
453
|
+
expect(result).toBeNull();
|
|
454
|
+
// A1 契约:空标题不再无痕——warn 带 model/stopReason/raw(截断=length 是上调 maxTokens 的数据信号)
|
|
455
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
456
|
+
"title empty after clean, skipping rename",
|
|
457
|
+
{ model: "stub/stub-model", stopReason: "length", raw: " " },
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("callLLM 返回非空但清洗后为空(纯标点)→ warn 的 raw 携带 ≤100 码点原始预览", async () => {
|
|
462
|
+
vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
|
|
463
|
+
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "。。。。。", stopReason: "stop" });
|
|
450
464
|
const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE);
|
|
451
465
|
expect(result).toBeNull();
|
|
466
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
467
|
+
"title empty after clean, skipping rename",
|
|
468
|
+
{ model: "stub/stub-model", stopReason: "stop", raw: "。。。。。" },
|
|
469
|
+
);
|
|
452
470
|
});
|
|
453
471
|
|
|
454
|
-
it("传给 callLLM 的 opts:model/systemPrompt(<200)/maxTokens=
|
|
472
|
+
it("传给 callLLM 的 opts:model/systemPrompt(<200)/maxTokens=2048/timeoutMs=30000/signal/sessionId/无 tools,messages 三段式", async () => {
|
|
455
473
|
vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
|
|
456
474
|
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "标题" });
|
|
457
475
|
await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE);
|
|
@@ -470,7 +488,8 @@ describe("callRenameLLM", () => {
|
|
|
470
488
|
expect(callOpts.model).toBe(STUB_MODEL);
|
|
471
489
|
expect(callOpts.systemPrompt).toBe(RENAME_SYSTEM_PROMPT);
|
|
472
490
|
expect(callOpts.systemPrompt.length).toBeLessThan(200);
|
|
473
|
-
|
|
491
|
+
// 输出预算覆盖 thinking+标题(reasoning 模型 thinking 600-1500 tokens 计入预算,64 必截断)
|
|
492
|
+
expect(callOpts.maxTokens).toBe(2048);
|
|
474
493
|
// 固定 30s 超时(超时归一 ok:false 走静默跳过)
|
|
475
494
|
expect(callOpts.timeoutMs).toBe(30000);
|
|
476
495
|
expect(callOpts.signal).toBeInstanceOf(AbortSignal);
|
|
@@ -655,6 +674,21 @@ describe("callRenameLLM", () => {
|
|
|
655
674
|
const callOpts = vi.mocked(callLLM).mock.calls[0][1] as { reasoning?: unknown };
|
|
656
675
|
expect(callOpts.reasoning).toBe("high");
|
|
657
676
|
});
|
|
677
|
+
|
|
678
|
+
it("thinkingLevel 为该模型不支持的档位 → warn 留痕 + 不传 reasoning(数据驱动判定,不静默换档)", async () => {
|
|
679
|
+
loggerMock.warn.mockClear();
|
|
680
|
+
vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
|
|
681
|
+
vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "标题" });
|
|
682
|
+
// STUB_MODEL(reasoning=true)的 supportedLevels = off/minimal/low/medium/high,
|
|
683
|
+
// "ultra" 词表外 → reasoningLevel undefined → 不传档位
|
|
684
|
+
await callRenameLLM(createCtx(), { ...BASE_CONFIG, thinkingLevel: "ultra" }, FINAL_MESSAGE);
|
|
685
|
+
|
|
686
|
+
const callOpts = vi.mocked(callLLM).mock.calls[0][1] as { reasoning?: unknown };
|
|
687
|
+
expect(callOpts.reasoning).toBeUndefined();
|
|
688
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
689
|
+
expect.stringContaining('thinkingLevel "ultra" is not supported'),
|
|
690
|
+
);
|
|
691
|
+
});
|
|
658
692
|
});
|
|
659
693
|
|
|
660
694
|
// ────────────────────────────────────────────────────
|
|
@@ -301,8 +301,10 @@ describe("normalizeRenameConfig", () => {
|
|
|
301
301
|
expect(normalizeRenameConfig({ thinkingLevel: "off" }).thinkingLevel).toBe("off");
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
-
it("thinkingLevel
|
|
305
|
-
|
|
304
|
+
it("thinkingLevel:非空字符串原样采信(含词表外值),非字符串/缺失 → 默认 off", () => {
|
|
305
|
+
// 词表不在本层(合法性归写入侧 UI 与 pi):显式写了未知档位不再被静默换成缺省档
|
|
306
|
+
expect(normalizeRenameConfig({ thinkingLevel: "ultra" }).thinkingLevel).toBe("ultra");
|
|
307
|
+
expect(normalizeRenameConfig({ thinkingLevel: "xhigh" }).thinkingLevel).toBe("xhigh");
|
|
306
308
|
expect(normalizeRenameConfig({ thinkingLevel: 5 }).thinkingLevel).toBe("off");
|
|
307
309
|
expect(normalizeRenameConfig({ thinkingLevel: null }).thinkingLevel).toBe("off");
|
|
308
310
|
// 缺失字段 → 默认 off(旧配置文件兼容:无 thinkingLevel 字段的存量配置自动回填)
|
package/src/llm.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
3
4
|
import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
|
|
4
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
@@ -155,9 +156,12 @@ export function buildTitleMessages(
|
|
|
155
156
|
|
|
156
157
|
// ──────────────────────── debug 证据链 + 超时 ────────────────────────
|
|
157
158
|
|
|
158
|
-
/** rename LLM
|
|
159
|
+
/** rename LLM 超时(固定值:实测 2048 预算下独立调用 5-12.5s,30s 约 2.4x+ 余量;超时归一 ok:false 静默跳过)。 */
|
|
159
160
|
const RENAME_TIMEOUT_MS = 30_000;
|
|
160
161
|
|
|
162
|
+
/** title empty warn 的 raw 预览上限(Unicode 码点数;预览是 LLM 标题候选,非对话原文)。 */
|
|
163
|
+
const TITLE_EMPTY_RAW_PREVIEW_CODE_POINTS = 100;
|
|
164
|
+
|
|
161
165
|
/** debug 开关 live 读(每次调用查 process.env,非模块加载时读——vi.stubEnv 可测 + 运行时可切换)。 */
|
|
162
166
|
export function isRenameDebugEnabled(): boolean {
|
|
163
167
|
return process.env.TAIJI_AGENT_DEBUG === "1";
|
|
@@ -265,6 +269,12 @@ export async function callRenameLLM(
|
|
|
265
269
|
logger.warn("model not available, skipping");
|
|
266
270
|
return null;
|
|
267
271
|
}
|
|
272
|
+
const reasoningLevel = getSupportedThinkingLevels(model).find((l) => l === config.thinkingLevel);
|
|
273
|
+
if (config.thinkingLevel !== undefined && reasoningLevel === undefined) {
|
|
274
|
+
logger.warn(
|
|
275
|
+
`configured thinkingLevel "${config.thinkingLevel}" is not supported by ${model.provider}/${model.id}; calling without a thinking level`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
268
278
|
|
|
269
279
|
const userPrompt =
|
|
270
280
|
options?.promptText ??
|
|
@@ -295,12 +305,19 @@ export async function callRenameLLM(
|
|
|
295
305
|
model,
|
|
296
306
|
systemPrompt: RENAME_SYSTEM_PROMPT,
|
|
297
307
|
messages,
|
|
298
|
-
//
|
|
299
|
-
|
|
308
|
+
// 输出预算必须覆盖「thinking + 标题」两部分:thinking 计入 maxTokens 的模型
|
|
309
|
+
//(StepFun step-5-preview 实测 thinking 600-1500 tokens 且不可禁用/不可压低,
|
|
310
|
+
// reasoning_effort/thinking 参数均被无视)在 64 预算下正文必然截断为空。
|
|
311
|
+
// 2048 = 实测最大总输出 843 tokens 的 2.4x 余量;不给 4096+ 是因为最坏满额时长
|
|
312
|
+
//(4096 ÷ 67 tok/s ≈ 61s)远超 30s 超时线。截断发生时空标题 warn 带 stopReason=length 留痕,
|
|
313
|
+
// 该 warn 累积出现即上调本常量的数据信号。maxTokens 是截断上限非预扣额度,按实际用量计费。
|
|
314
|
+
maxTokens: 2048,
|
|
300
315
|
// 固定 30s 超时(网络抖动归一为 ok:false 走静默跳过,不悬挂 fire-and-forget promise)
|
|
301
316
|
timeoutMs: RENAME_TIMEOUT_MS,
|
|
302
|
-
//
|
|
303
|
-
|
|
317
|
+
// 档位按「该模型自己的 supportedLevels」判定(pi-ai 数据驱动,本层不自持词表):
|
|
318
|
+
// 配置写了该模型不支持的档位 → 留痕并按「不传档位」处理(不静默换成别的档)。
|
|
319
|
+
// llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)。
|
|
320
|
+
reasoning: reasoningLevel,
|
|
304
321
|
// 保留随 session abort 取消调用的语义(旧版 llm.ts 同样透传 ctx.signal)
|
|
305
322
|
signal: ctx.signal,
|
|
306
323
|
sessionId,
|
|
@@ -327,6 +344,15 @@ export async function callRenameLLM(
|
|
|
327
344
|
|
|
328
345
|
const title = cleanTitle(result.content, config.maxTitleLength);
|
|
329
346
|
if (!title) {
|
|
347
|
+
// A1 契约(不可用不静默):调用成功但标题为空是可排查的失败形态,不走 debugLog——
|
|
348
|
+
// warn 经 appendEntry 无条件写 session 文件(rename-session:log entry),任何环境可 grep。
|
|
349
|
+
// stopReason=length = 输出预算被 thinking 吃光(上调 maxTokens 的数据信号);
|
|
350
|
+
// stopReason=stop = 模型正常返回空/纯标点文本(换标题模型)。raw 是 LLM 生成的标题候选非对话原文。
|
|
351
|
+
logger.warn("title empty after clean, skipping rename", {
|
|
352
|
+
model: `${model.provider}/${model.id}`,
|
|
353
|
+
stopReason: result.stopReason,
|
|
354
|
+
raw: truncateCodePoints(result.content, TITLE_EMPTY_RAW_PREVIEW_CODE_POINTS, "…"),
|
|
355
|
+
});
|
|
330
356
|
debugLog("skip: title empty");
|
|
331
357
|
return null;
|
|
332
358
|
}
|
package/src/pure.ts
CHANGED
|
@@ -2,11 +2,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
5
|
|
|
7
6
|
import { isEnoentError } from "@zhushanwen/pi-ext-guards";
|
|
8
7
|
import {
|
|
9
|
-
|
|
8
|
+
normalizeThinkingLevel,
|
|
10
9
|
loadConfig,
|
|
11
10
|
normalizeModelSelector,
|
|
12
11
|
saveConfig,
|
|
@@ -40,11 +39,11 @@ export interface RenameSessionConfig {
|
|
|
40
39
|
/** 标题最大长度(Unicode 码点数)。 */
|
|
41
40
|
maxTitleLength: number;
|
|
42
41
|
/**
|
|
43
|
-
* 标题生成 LLM 的 thinking
|
|
42
|
+
* 标题生成 LLM 的 thinking 级别(字符串透传;合法档位按模型 supportedLevels 在调用点判定)。
|
|
44
43
|
* 默认 "off":不传 pi-ai reasoning(provider 默认行为,与旧版本一致);
|
|
45
44
|
* "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
|
|
46
45
|
*/
|
|
47
|
-
thinkingLevel:
|
|
46
|
+
thinkingLevel: string;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
// ──────────────────────── 枚举校验 ────────────────────────
|
|
@@ -144,9 +143,7 @@ export function normalizeRenameConfig(raw: unknown): RenameSessionConfig {
|
|
|
144
143
|
// normalizeModelSelector:llm-shared 导出(ext-simplify-17 D6 收口)
|
|
145
144
|
const model = normalizeModelSelector(obj.model) ?? DEFAULT_RENAME_CONFIG.model;
|
|
146
145
|
|
|
147
|
-
const thinkingLevel =
|
|
148
|
-
? obj.thinkingLevel
|
|
149
|
-
: DEFAULT_RENAME_CONFIG.thinkingLevel;
|
|
146
|
+
const thinkingLevel = normalizeThinkingLevel(obj.thinkingLevel) ?? DEFAULT_RENAME_CONFIG.thinkingLevel;
|
|
150
147
|
|
|
151
148
|
return { enabled, model, mode, maxTitleLength, thinkingLevel };
|
|
152
149
|
}
|