@zhushanwen/pi-rename-session 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-rename-session",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@zhushanwen/pi-extension-logger": "0.4.1",
36
- "@zhushanwen/pi-llm-shared": "0.5.1"
36
+ "@zhushanwen/pi-llm-shared": "0.6.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@vitest/coverage-v8": "^4.1.9",
@@ -28,7 +28,7 @@ describe("executeAutoRenameCommand", () => {
28
28
  if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
29
29
  else process.env.PI_CODING_AGENT_DIR = origEnv;
30
30
  clearConfigCache();
31
- fs.rmSync(tmpAgentDir, { recursive: true, force: true });
31
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
32
32
  });
33
33
 
34
34
  function configPath(): string {
@@ -10,7 +10,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
10
10
  setPiHandle: vi.fn(),
11
11
  }));
12
12
 
13
- import type { Api, Model } from "@earendil-works/pi-ai";
13
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
@@ -50,6 +50,8 @@ interface MockSetup {
50
50
  setSessionNameMock: ReturnType<typeof vi.fn>;
51
51
  /** 防覆盖检查读 pi.getSessionName()(D5),非 ctx——默认未命名(undefined)。 */
52
52
  getSessionNameMock: ReturnType<typeof vi.fn>;
53
+ /** usage 落账入口(设计 §3.3 ③):index.ts 注入回调体内调 pi.appendEntry,接线断言用。 */
54
+ appendEntryMock: ReturnType<typeof vi.fn>;
53
55
  turnEndHandler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
54
56
  }
55
57
 
@@ -83,6 +85,7 @@ const DISABLED_CONFIG: RenameSessionConfig = {
83
85
  function createMockPi(): MockSetup {
84
86
  const setSessionNameMock = vi.fn();
85
87
  const getSessionNameMock = vi.fn((): string | undefined => undefined);
88
+ const appendEntryMock = vi.fn();
86
89
  let turnEndHandler!: MockSetup["turnEndHandler"];
87
90
  const pi = {
88
91
  on: vi.fn((event: string, handler: MockSetup["turnEndHandler"]) => {
@@ -91,11 +94,13 @@ function createMockPi(): MockSetup {
91
94
  registerCommand: vi.fn(),
92
95
  getSessionName: getSessionNameMock,
93
96
  setSessionName: setSessionNameMock,
97
+ appendEntry: appendEntryMock,
94
98
  } as unknown as ExtensionAPI;
95
99
  return {
96
100
  pi,
97
101
  setSessionNameMock,
98
102
  getSessionNameMock,
103
+ appendEntryMock,
99
104
  get turnEndHandler() {
100
105
  return turnEndHandler;
101
106
  },
@@ -486,3 +491,86 @@ describe("renameSessionExtension", () => {
486
491
  expect(nonA1Calls).toHaveLength(0);
487
492
  });
488
493
  });
494
+
495
+ // ────────────────────────────────────────────────────
496
+ // usage 落账接线(appendUsageEntry 回调注入,设计 §3.3 ③ / §3.6)
497
+ // ────────────────────────────────────────────────────
498
+
499
+ /** 合法 Usage 夹具(pi-ai Usage 全必填字段,消除 unsafe-cast 强断言)。 */
500
+ const STUB_USAGE: Usage = {
501
+ input: 10,
502
+ output: 5,
503
+ cacheRead: 2,
504
+ cacheWrite: 1,
505
+ totalTokens: 18,
506
+ cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0, total: 0.03 },
507
+ };
508
+
509
+ describe("usage 落账接线(appendUsageEntry,设计 §3.3 ③)", () => {
510
+ let setup: MockSetup;
511
+
512
+ beforeEach(() => {
513
+ vi.clearAllMocks();
514
+ vi.mocked(loadRenameConfig).mockReset();
515
+ vi.mocked(resolveModel).mockReset();
516
+ vi.mocked(callLLM).mockReset();
517
+ vi.mocked(loadRenameConfig).mockReturnValue(ENABLED_CONFIG);
518
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
519
+ setup = createMockPi();
520
+ renameSessionExtension(setup.pi);
521
+ });
522
+
523
+ it("接线:callLLM ok:true + usage → pi.appendEntry 恰被调一次 ('rename-session', {model, usage}),且先于 setSessionName(标题照常落库)", async () => {
524
+ vi.mocked(callLLM).mockResolvedValue({
525
+ ok: true,
526
+ content: "自动生成的标题",
527
+ usage: STUB_USAGE,
528
+ });
529
+
530
+ await fire(setup, createMockCtx());
531
+ await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("自动生成的标题"));
532
+
533
+ expect(setup.appendEntryMock).toHaveBeenCalledTimes(1);
534
+ expect(setup.appendEntryMock).toHaveBeenCalledWith("rename-session", {
535
+ model: "stub/stub-model",
536
+ usage: STUB_USAGE,
537
+ });
538
+ // 时序:appendEntry 在 callRenameLLM 内(cleanTitle 前)触发,setSessionName 在其后 .then——落账先于落库
539
+ expect(setup.appendEntryMock.mock.invocationCallOrder[0]).toBeLessThan(
540
+ setup.setSessionNameMock.mock.invocationCallOrder[0],
541
+ );
542
+ });
543
+
544
+ it("catch 在回调体内:pi.appendEntry 抛错(session 已切换等)→ logger.error 且后续流程照常(标题照常落库)", async () => {
545
+ vi.mocked(callLLM).mockResolvedValue({
546
+ ok: true,
547
+ content: "自动生成的标题",
548
+ usage: STUB_USAGE,
549
+ });
550
+ setup.appendEntryMock.mockImplementation(() => {
551
+ throw new Error("session switched");
552
+ });
553
+
554
+ await fire(setup, createMockCtx());
555
+ // 「标题照常落库」(§3.6):appendEntry 抛错被回调体内 catch,detached 链继续走 cleanTitle → setSessionName
556
+ await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("自动生成的标题"));
557
+
558
+ expect(loggerMock.error).toHaveBeenCalledWith("failed to append usage entry", {
559
+ error: "Error: session switched",
560
+ });
561
+ // 外层 .catch(rename LLM failed)不应被触发——错误在回调体内已被吞掉
562
+ const outerCatchCalls = loggerMock.error.mock.calls.filter((c) =>
563
+ String(c[0]).includes("rename LLM failed"),
564
+ );
565
+ expect(outerCatchCalls).toHaveLength(0);
566
+ });
567
+
568
+ it("usage 缺失 → appendEntry 不被调(§3.6 存在性守卫),标题照常落库", async () => {
569
+ vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "自动生成的标题" });
570
+
571
+ await fire(setup, createMockCtx());
572
+ await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("自动生成的标题"));
573
+
574
+ expect(setup.appendEntryMock).not.toHaveBeenCalled();
575
+ });
576
+ });
@@ -10,7 +10,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
10
10
  setPiHandle: vi.fn(),
11
11
  }));
12
12
 
13
- import type { Api, Model } from "@earendil-works/pi-ai";
13
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
@@ -21,6 +21,13 @@ vi.mock("@zhushanwen/pi-llm-shared", async (importActual) => {
21
21
  return { ...actual, resolveModel: vi.fn(), callLLM: vi.fn() };
22
22
  });
23
23
 
24
+ // mock pure.js:只把 cleanTitle 包一层 vi.fn(委托真实实现,行为零变化)——
25
+ // 「回调在 cleanTitle 之前」的时序断言需要 cleanTitle 可观测(invocationCallOrder 比较)。
26
+ vi.mock("../pure.js", async (importActual) => {
27
+ const actual = await importActual<typeof import("../pure.js")>();
28
+ return { ...actual, cleanTitle: vi.fn(actual.cleanTitle) };
29
+ });
30
+
24
31
  // 被测模块须在 vi.mock 之后 import
25
32
  import { callLLM, resolveModel } from "@zhushanwen/pi-llm-shared";
26
33
 
@@ -34,7 +41,7 @@ import {
34
41
  isSubagentSession,
35
42
  truncateForTitle,
36
43
  } from "../llm.js";
37
- import { type RenameSessionConfig } from "../pure.js";
44
+ import { cleanTitle, type RenameSessionConfig } from "../pure.js";
38
45
 
39
46
  // 每用例收尾统一还原:logger mock 恢复 + stub 的 XYZ_AGENT_DEBUG 还原,
40
47
  // 防泄漏到后续用例(debug 开关 live 读 process.env,依赖 stubEnv/unstubAllEnvs 成对)
@@ -549,6 +556,106 @@ describe("callRenameLLM", () => {
549
556
  });
550
557
  });
551
558
 
559
+ // ────────────────────────────────────────────────────
560
+ // usage 落账回调(appendUsageEntry 注入,设计 §3.3 ③ / §3.6)
561
+ // ────────────────────────────────────────────────────
562
+
563
+ /** 合法 Usage 夹具(pi-ai Usage 全必填字段,消除 unsafe-cast 强断言)。 */
564
+ const STUB_USAGE: Usage = {
565
+ input: 10,
566
+ output: 5,
567
+ cacheRead: 2,
568
+ cacheWrite: 1,
569
+ totalTokens: 18,
570
+ cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0, total: 0.03 },
571
+ };
572
+
573
+ describe("callRenameLLM usage 落账回调(appendUsageEntry,设计 §3.3 ③)", () => {
574
+ beforeEach(() => {
575
+ vi.clearAllMocks();
576
+ });
577
+
578
+ it("usage 存在 + 注入回调 → 恰被调一次、参数 (provider/id, usage 同一引用),且在 cleanTitle 之前", async () => {
579
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
580
+ vi.mocked(callLLM).mockResolvedValue({
581
+ ok: true,
582
+ content: " 修复登录bug ",
583
+ usage: STUB_USAGE,
584
+ });
585
+ const appendUsageEntry = vi.fn();
586
+
587
+ const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE, {
588
+ appendUsageEntry,
589
+ });
590
+
591
+ expect(result).toBe("修复登录bug");
592
+ expect(appendUsageEntry).toHaveBeenCalledTimes(1);
593
+ expect(appendUsageEntry).toHaveBeenCalledWith("stub/stub-model", STUB_USAGE);
594
+ // 时点契约(§3.3 ③):ok:true && usage 后立即、cleanTitle 之前
595
+ expect(appendUsageEntry.mock.invocationCallOrder[0]).toBeLessThan(
596
+ vi.mocked(cleanTitle).mock.invocationCallOrder[0],
597
+ );
598
+ });
599
+
600
+ it("计量与标题清洗成败解耦:content 清洗后为空(rename 跳过返回 null)→ 回调仍恰被调一次", async () => {
601
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
602
+ vi.mocked(callLLM).mockResolvedValue({ ok: true, content: " ", usage: STUB_USAGE });
603
+ const appendUsageEntry = vi.fn();
604
+
605
+ const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE, {
606
+ appendUsageEntry,
607
+ });
608
+
609
+ expect(result).toBeNull();
610
+ expect(appendUsageEntry).toHaveBeenCalledTimes(1);
611
+ expect(appendUsageEntry).toHaveBeenCalledWith("stub/stub-model", STUB_USAGE);
612
+ });
613
+
614
+ it("usage 缺失(provider 不回)→ 跳过回调不落账(§3.6 存在性守卫),标题照常返回", async () => {
615
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
616
+ vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "修复登录bug" });
617
+ const appendUsageEntry = vi.fn();
618
+
619
+ const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE, {
620
+ appendUsageEntry,
621
+ });
622
+
623
+ expect(result).toBe("修复登录bug");
624
+ expect(appendUsageEntry).not.toHaveBeenCalled();
625
+ });
626
+
627
+ it("回调内部抛错被回调实现 catch(§3.6 契约,模拟 index.ts 注入的真实回调)→ 不阻断 cleanTitle 流程,标题照常返回", async () => {
628
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
629
+ vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "修复登录bug", usage: STUB_USAGE });
630
+ const appendUsageEntry = vi.fn((_model: string, _usage: Usage) => {
631
+ try {
632
+ throw new Error("session switched"); // 模拟 pi.appendEntry 抛错(session 已切换等)
633
+ } catch {
634
+ // 回调体内 catch(§3.6:catch 必须位于回调实现内部)——index.ts 注入实现同款
635
+ }
636
+ });
637
+
638
+ const result = await callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE, {
639
+ appendUsageEntry,
640
+ });
641
+
642
+ expect(result).toBe("修复登录bug");
643
+ expect(appendUsageEntry).toHaveBeenCalledTimes(1);
644
+ });
645
+
646
+ it("违约回调(实现未自吞错直接抛出)→ callRenameLLM reject(llm.ts 不吞错——§3.6 catch 归属钉死回调实现内部,防双重 catch 漂移;真实接线的兜底由 index.ts 回调体内 catch + 外层 .catch 覆盖)", async () => {
647
+ vi.mocked(resolveModel).mockReturnValue(STUB_MODEL);
648
+ vi.mocked(callLLM).mockResolvedValue({ ok: true, content: "修复登录bug", usage: STUB_USAGE });
649
+ const appendUsageEntry = vi.fn(() => {
650
+ throw new Error("contract violation");
651
+ });
652
+
653
+ await expect(
654
+ callRenameLLM(createCtx(), BASE_CONFIG, FINAL_MESSAGE, { appendUsageEntry }),
655
+ ).rejects.toThrow("contract violation");
656
+ });
657
+ });
658
+
552
659
  // ────────────────────────────────────────────────────
553
660
  // A1 日志(失败路径 + 成功路径可排查,契约 C1 文案锁定)
554
661
  // ────────────────────────────────────────────────────
@@ -302,7 +302,7 @@ describe("环境变量覆盖", () => {
302
302
  else process.env.PI_RENAME_THINKING_LEVEL = origThinkingLevel;
303
303
 
304
304
  clearConfigCache();
305
- fs.rmSync(tmpAgentDir, { recursive: true, force: true });
305
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
306
306
  });
307
307
 
308
308
  it("PI_RENAME_ENABLED=true → enabled=true", () => {
@@ -427,7 +427,7 @@ describe("loadRenameConfig / saveRenameConfig", () => {
427
427
  if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
428
428
  else process.env.PI_CODING_AGENT_DIR = origEnv;
429
429
  clearConfigCache();
430
- fs.rmSync(tmpAgentDir, { recursive: true, force: true });
430
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
431
431
  });
432
432
 
433
433
  it("配置路径 = <agentDir>/config/rename-session-ext-config.json(走 getAgentDir,实例隔离)", () => {
package/src/index.ts CHANGED
@@ -67,7 +67,21 @@ export default function renameSessionExtension(pi: ExtensionAPI): void {
67
67
  // 5. LLM 生成标题并落库。pi 运行时的事件链是 await 的(runner.emit → await handler),
68
68
  // 若 await callRenameLLM 会阻塞 agent 进入下一次迭代。这里用 detached promise 脱离 await 链,
69
69
  // 真正实现 fire-and-forget:handler 立即 resolve,LLM 调用与 setSessionName 在后台异步完成。
70
- void callRenameLLM(ctx, config, event.message)
70
+ void callRenameLLM(ctx, config, event.message, {
71
+ // usage 落账回调注入(设计 §3.3 ③):闭包捕获 pi,把 rename LLM 调用的 usage 以
72
+ // custom entry 落盘(pi.appendEntry → {type:"custom",customType:"rename-session",
73
+ // data:{model,usage},timestamp},不进对话流不进 LLM 上下文)。调用时点
74
+ // (ok:true && usage 后立即、cleanTitle 前)由 llm.ts 统一规定。
75
+ appendUsageEntry: (model, usage) => {
76
+ // §3.6:catch 必须位于回调实现内部——appendEntry 抛错(session 已切换等)只记
77
+ // 日志,不影响回调返回与后续 cleanTitle/setSessionName(「标题照常落库」)。
78
+ try {
79
+ pi.appendEntry("rename-session", { model, usage });
80
+ } catch (e) {
81
+ logger.error("failed to append usage entry", { error: String(e) });
82
+ }
83
+ },
84
+ })
71
85
  .then((title) => {
72
86
  if (!title) return;
73
87
  // 防覆盖(D5):落库前重查——LLM 调用窗口(2-30s)内用户手动命名的竞态由此兜住
package/src/llm.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
3
+ import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { getLogger } from "@zhushanwen/pi-extension-logger";
6
6
  import { callLLM, resolveModel } from "@zhushanwen/pi-llm-shared";
@@ -195,6 +195,23 @@ function messageText(message: Message): string {
195
195
 
196
196
  // ──────────────────────── LLM 调用 ────────────────────────
197
197
 
198
+ // ──────────────────────── 注入项(usage 落账,设计 §3.3 ③) ────────────────────────
199
+
200
+ /**
201
+ * callRenameLLM 可选注入项。llm.ts 不依赖 pi 句柄(ExtensionAPI)——依赖 pi 的副作用由
202
+ * index.ts 调用处注入回调(闭包捕获 pi),本模块只定义回调形状与调用时点。
203
+ */
204
+ export interface CallRenameLLMOptions {
205
+ /**
206
+ * usage 落账回调(设计 §3.3 ③):`callLLM` 返回 `ok:true && usage` 存在后**立即**、
207
+ * `cleanTitle` 之前调用——计量「LLM 调用事实」与标题清洗成败解耦(标题清洗为空导致
208
+ * rename 跳过时,调用用量照常落账)。`usage` 缺失时不调用(§3.6 存在性守卫)。
209
+ * 契约(§3.6):catch 必须位于回调实现内部(index.ts 侧 try/catch + logger.error),
210
+ * 不向调用方抛错;本函数不包裹 try/catch(catch 归属钉死回调体内,防双重 catch 漂移)。
211
+ */
212
+ appendUsageEntry?: (model: string, usage: Usage) => void;
213
+ }
214
+
198
215
  /**
199
216
  * 发起 rename LLM 调用,返回提取+清洗后的标题(空串/异常返回 null 表示应跳过 rename)。
200
217
  *
@@ -209,6 +226,8 @@ function messageText(message: Message): string {
209
226
  * - tools:不传(callLLM 内部显式 tools:[];旧版 `pi.getAllTools()` 塞全部工具,纯浪费 token)
210
227
  * - model 不可用(resolveModel 返回 null)→ 静默跳过返回 null,不报错不阻断
211
228
  * - signal:透传 ctx.signal(保留旧版随 session abort 取消的语义)
229
+ * - options.appendUsageEntry:usage 落账回调注入(§3.3 ③——时点 ok:true && usage 后立即、
230
+ * cleanTitle 前;catch 归属回调实现内部,§3.6)
212
231
  *
213
232
  * 本函数是 async(内部 await callLLM,这是 callRenameLLM 自身流程);
214
233
  * 调用方(turn_end handler)用 fire-and-forget 包裹(`void callRenameLLM(...).then(...).catch(...)`),
@@ -218,6 +237,7 @@ export async function callRenameLLM(
218
237
  ctx: ExtensionContext,
219
238
  config: RenameSessionConfig,
220
239
  finalMessage: unknown,
240
+ options?: CallRenameLLMOptions,
221
241
  ): Promise<string | null> {
222
242
  // 内部顺序不可调换(E2E 竞态断言依赖「内省日志在请求发起前打出」):
223
243
  // resolveModel → extract prompt → extract finalText → truncate ×2 → build → debug 内省 → callLLM
@@ -273,6 +293,14 @@ export async function callRenameLLM(
273
293
  return null;
274
294
  }
275
295
 
296
+ // usage 落账(设计 §3.3 ③):ok:true && usage 存在后立即、cleanTitle 之前——「LLM 调用
297
+ // 事实」的计量与标题清洗成败解耦(cleanTitle 为空跳过 rename 不影响已落账);usage 缺失
298
+ // (provider 不回)→ 跳过回调不落账(§3.6 存在性守卫)。回调契约自带 catch(§3.6 归属
299
+ // 回调实现内部),此处不包裹 try/catch。
300
+ if (options?.appendUsageEntry && result.usage) {
301
+ options.appendUsageEntry(`${model.provider}/${model.id}`, result.usage);
302
+ }
303
+
276
304
  // A1 成功路径日志(B2+B3 修正):默认不输出,避免常开 console.warn 污染 Pi 输入框;
277
305
  // 需要排查时设 XYZ_AGENT_DEBUG=1,经 debugLog 输出(带时间戳)。
278
306
  // - B2 位置:原在 callLLM 调用前打出,失败时会误导(日志已落但 rename 未发生);移到 result.ok 确认后