@andares/pi-toolkits 0.2.0 → 0.3.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 CHANGED
@@ -11,8 +11,8 @@
11
11
  [![pi >= 0.84](https://img.shields.io/badge/pi-%3E%3D0.84.0-blueviolet)](https://github.com/earendil-works/pi)
12
12
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
13
13
 
14
- 一句话介绍:目前包含三个开箱即用的功能——**只读咨询模式** `ask`、**模型收藏** `favorites`、**提示词暂存** `stash`,后续持续追加。
15
- Currently ships three ready-to-use capabilities — **ask mode**, **model favorites** and **prompt stash** — with more to come.
14
+ 一句话介绍:目前包含四个开箱即用的功能——**只读咨询模式** `ask`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`,后续持续追加。
15
+ Currently ships four ready-to-use capabilities — **ask mode**, **model favorites**, **prompt stash** and **third-party code review** — with more to come.
16
16
 
17
17
  ---
18
18
 
@@ -36,6 +36,7 @@ pi -e ./src/index.ts
36
36
  - `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit` 被硬禁用
37
37
  - `/model`(或 `ctrl+l`)→ 选择器顶部出现收藏提示行
38
38
  - 输入框内按 `ctrl+alt+y` → 当前提示词被暂存并清空输入框
39
+ - 完成一个开发任务后输入 `third-review` → 切换到评审模型执行查+修
39
40
 
40
41
  ---
41
42
 
@@ -46,6 +47,7 @@ pi -e ./src/index.ts
46
47
  - [🛡️ ask — 只读咨询模式](#ask)
47
48
  - [⭐ favorites — 模型收藏](#favorites)
48
49
  - [📥 stash — 提示词暂存](#stash)
50
+ - [🔎 review — 第三方代码评审](#review)
49
51
  - [开发 Development](#development)
50
52
  - [贡献 Contributing](#contributing)
51
53
  - [许可 License](#license)
@@ -61,6 +63,7 @@ pi -e ./src/index.ts
61
63
  | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入只读问答模式,`write`/`edit` 硬禁用,`bash` 只读沙箱 |
62
64
  | ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+F` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
63
65
  | 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
66
+ | 🔎 **review** 第三方代码评审 | `third-review` / `/third-review` | 召唤配置的评审模型,对刚完成的任务做查+修,重点检查「本轮需求实现 / 是否有遗漏 / 是否有错误」 |
64
67
 
65
68
  ---
66
69
 
@@ -137,6 +140,31 @@ pi -e ./src/index.ts
137
140
 
138
141
  ---
139
142
 
143
+ ## 🔎 review — 第三方代码评审
144
+
145
+ <a name="review"></a>
146
+
147
+ 开发任务完成后,输入 `third-review`(或 `third review`、`/third-review`)召唤**另一个模型**以第三方身份,对刚完成的代码做「查 + 修」:评审模型拥有**完整工具权限**,确认的问题直接改,不是只读审计。评审回合留在当前会话记录里,评审完成后**会话保持评审模型**(不自动切回)。
148
+
149
+ **评审提示词重点检查三点**(逐项给出结论):
150
+
151
+ 1. **本轮需求实现** —— 需求是否已完整、正确地实现,与需求描述是否一致
152
+ 2. **是否有遗漏** —— 功能点、边界情况、异常处理、兼容性、测试等
153
+ 3. **是否有错误** —— 逻辑错误、潜在 bug、类型/编译错误、安全与性能隐患等
154
+
155
+ **评审模型配置**(`<agent-dir>/pi-toolkits-review.json`,默认 `~/.pi/agent/`):
156
+
157
+ ```json
158
+ { "model": "anthropic/claude-sonnet-4" }
159
+ ```
160
+
161
+ - `/review-model` —— 查看/更换评审模型:弹出模型列表(标题显示当前配置),选中即写入;取消则不修改配置
162
+ - **未配置 / 配置的模型已不可用**(如在 pi 的 models.json 里删掉了该模型)→ 提示「评审模型不存在」,弹出**可用模型选择列表**(与 `/model` 同源);选中后**写回配置并立即执行**
163
+ - 任务仍在进行中(agent 未空闲)时触发 → 提示等待,不执行;同一时间只允许一个评审
164
+ - footer 出现灰色 `review` 状态 = 已配置评审模型
165
+
166
+ ---
167
+
140
168
  ## 🛠️ 开发 Development
141
169
 
142
170
  <a name="development"></a>
@@ -160,6 +188,8 @@ src/
160
188
  │ # system prompt banner, tests
161
189
  ├── favorites/ # model favorites: selector + cycle patches,
162
190
  │ # persisted store, tests
191
+ ├── review/ # third-party review: input/command triggers,
192
+ │ # review-model config store, prompt, tests
163
193
  └── stash/ # prompt stash: hotkey state machine + tests
164
194
  ```
165
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Integrated toolkit extension for pi — currently provides ask mode (read-only Q&A); more features to come",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit",
44
44
  "test": "vitest run",
45
- "release": "node ./scripts/publish.mjs"
45
+ "release": "node ./scripts/publish.mjs",
46
+ "tag-current": "node ./scripts/tag-current.mjs"
46
47
  }
47
48
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * review feature — constants.
3
+ *
4
+ * The review-model config lives in `<agent-dir>/pi-toolkits-review.json`
5
+ * (same agent-dir convention as the favorites store) and holds a single
6
+ * model key `"provider/id"` — see store.ts.
7
+ */
8
+
9
+ /** Config file name inside the pi agent dir. */
10
+ export const REVIEW_FILE = "pi-toolkits-review.json";
11
+
12
+ /** Footer status key shown while a review model is configured. */
13
+ export const REVIEW_STATUS_KEY = "review";
@@ -0,0 +1,232 @@
1
+ /**
2
+ * review feature — third-review command.
3
+ *
4
+ * After a development task finishes, "summon another model" to review the
5
+ * just-completed work: the configured review model takes over the session
6
+ * (full tool access — this is check AND fix, not a read-only audit), runs a
7
+ * review turn, and the session then stays on that model (no auto-restore).
8
+ *
9
+ * Trigger paths (both share createReviewRunner()):
10
+ * - bare message: typing exactly "third-review" / "third review" in the
11
+ * input box (pi.on("input") interception, swallowed with "handled")
12
+ * - slash command: /third-review
13
+ *
14
+ * Review-model config lives in `<agent-dir>/pi-toolkits-review.json`
15
+ * ("provider/id", see store.ts). When no model is configured, or the
16
+ * configured model is gone/unavailable (e.g. deleted from pi's models.json),
17
+ * the user is told the review model doesn't exist and is shown the available
18
+ * model list (ctx.ui.select, same getAvailable() source as /model); the pick
19
+ * is written back to the config and the review runs immediately.
20
+ *
21
+ * Guards: never fires while the agent is streaming (the review needs the
22
+ * just-finished work as context) and never runs two reviews concurrently.
23
+ */
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ Theme,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import type { Api, Model } from "@earendil-works/pi-ai";
30
+ import { REVIEW_STATUS_KEY } from "./constants.js";
31
+ import { REVIEW_PROMPT, isReviewTrigger } from "./prompt.js";
32
+ import { getReviewStore, modelKey, type ReviewStore } from "./store.js";
33
+
34
+ /** Resolve the configured review model against the available model list. */
35
+ export function resolveConfiguredModel(
36
+ ctx: ExtensionContext,
37
+ key: string | undefined,
38
+ ): Model<Api> | undefined {
39
+ if (!key) return undefined;
40
+ const slash = key.indexOf("/");
41
+ if (slash <= 0 || slash === key.length - 1) return undefined;
42
+ const provider = key.slice(0, slash);
43
+ const id = key.slice(slash + 1);
44
+ return ctx.modelRegistry
45
+ .getAvailable()
46
+ .find((m) => m.provider === provider && m.id === id);
47
+ }
48
+
49
+ /**
50
+ * Let the user pick a review model from the available list and persist the
51
+ * choice. Returns the picked model, or undefined when cancelled/unavailable.
52
+ *
53
+ * The picker title shows the current config so /review-model doubles as a
54
+ * view-and-change entry point; cancelling leaves the config untouched.
55
+ */
56
+ export async function pickReviewModel(
57
+ ctx: ExtensionContext,
58
+ store: ReviewStore,
59
+ ): Promise<Model<Api> | undefined> {
60
+ // Dialog-capable UI exists in TUI and RPC modes (hasUI); json/print and
61
+ // headless contexts have no dialogs, so fall back to a manual-config hint.
62
+ if (!ctx.hasUI) {
63
+ ctx.ui.notify(
64
+ "third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
65
+ "error",
66
+ );
67
+ return undefined;
68
+ }
69
+ const available = ctx.modelRegistry.getAvailable();
70
+ if (available.length === 0) {
71
+ ctx.ui.notify(
72
+ "third-review: 当前没有可用模型,请先在 pi 中配置模型。",
73
+ "error",
74
+ );
75
+ return undefined;
76
+ }
77
+ const configured = store.getModel();
78
+ // Same display source as the /model selector: name + [provider]/id.
79
+ const options = available.map((m) => `${m.name} (${m.provider}/${m.id})`);
80
+ const choice = await ctx.ui.select(
81
+ configured
82
+ ? `选择评审模型 (当前: ${configured})`
83
+ : "选择评审模型 (third-review)",
84
+ options,
85
+ );
86
+ if (choice === undefined) {
87
+ ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
88
+ return undefined;
89
+ }
90
+ const model = available[options.indexOf(choice)];
91
+ if (!model) return undefined;
92
+ store.setModel(modelKey(model));
93
+ return model;
94
+ }
95
+
96
+ /**
97
+ * Create the third-review runner bound to a pi instance.
98
+ *
99
+ * Exported separately from registerReview so tests can drive the flow with
100
+ * fake pi/ctx objects. The returned runner serializes concurrent triggers
101
+ * (one review at a time).
102
+ */
103
+ export function createReviewRunner(
104
+ pi: ExtensionAPI,
105
+ ): (ctx: ExtensionContext) => Promise<void> {
106
+ let running = false;
107
+
108
+ return async (ctx) => {
109
+ if (running) {
110
+ ctx.ui.notify("third-review: 评审已在执行中,请稍候。", "warning");
111
+ return;
112
+ }
113
+ if (!ctx.isIdle()) {
114
+ ctx.ui.notify(
115
+ "third-review: 当前任务仍在进行中,请等待完成后再触发。",
116
+ "warning",
117
+ );
118
+ return;
119
+ }
120
+ running = true;
121
+ try {
122
+ const store = getReviewStore();
123
+ const configured = store.getModel();
124
+ let model = resolveConfiguredModel(ctx, configured);
125
+ if (!model) {
126
+ ctx.ui.notify(
127
+ configured
128
+ ? `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`
129
+ : "third-review: 尚未配置评审模型,请选择。",
130
+ "warning",
131
+ );
132
+ model = await pickReviewModel(ctx, store);
133
+ if (!model) return;
134
+ }
135
+ const switched = await pi.setModel(model);
136
+ if (!switched) {
137
+ ctx.ui.notify(
138
+ `third-review: 无法切换到评审模型「${model.name}」(缺少 API key 或鉴权未配置)。`,
139
+ "error",
140
+ );
141
+ return;
142
+ }
143
+ ctx.ui.notify(
144
+ `third-review: 已切换到评审模型「${model.name}」执行评审(查+修);评审完成后会话保持该模型。`,
145
+ "info",
146
+ );
147
+ pi.sendUserMessage(REVIEW_PROMPT);
148
+ } finally {
149
+ running = false;
150
+ }
151
+ };
152
+ }
153
+
154
+ export function registerReview(pi: ExtensionAPI): void {
155
+ const runReview = createReviewRunner(pi);
156
+
157
+ // Bare-message trigger: "third-review" / "third review" in the input box.
158
+ // Extension-injected messages are ignored so the runner's own prompts
159
+ // never re-trigger this path.
160
+ pi.on("input", (event, ctx) => {
161
+ if (event.source === "extension") {
162
+ return { action: "continue" };
163
+ }
164
+ if (isReviewTrigger(event.text)) {
165
+ // Fire-and-forget: the runner uses the live ctx proxy, so it is safe
166
+ // after this handler returns. A session switch mid-dialog can make
167
+ // the context assert-inactive; swallow that instead of leaking an
168
+ // unhandled rejection (a fresh extension instance owns the new
169
+ // session anyway).
170
+ void runReview(ctx).catch((error) => {
171
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
172
+ console.warn(
173
+ "[pi-toolkits/review] third-review aborted:",
174
+ error instanceof Error ? error.message : String(error),
175
+ );
176
+ });
177
+ return { action: "handled" };
178
+ }
179
+ return { action: "continue" };
180
+ });
181
+
182
+ // Slash-command trigger (pi resolves commands before the input event, so
183
+ // the two paths never double-fire).
184
+ pi.registerCommand("third-review", {
185
+ description:
186
+ "Summon the configured review model to review the just-completed work (check + fix)",
187
+ handler: async (_args, ctx) => {
188
+ await runReview(ctx);
189
+ },
190
+ });
191
+
192
+ // Manual config entry point: view and change the review model. Always
193
+ // opens the picker (title shows the current config); cancelling keeps
194
+ // the existing config, so it is also safe as a view-only command.
195
+ pi.registerCommand("review-model", {
196
+ description:
197
+ "Set or change the review model used by third-review (picker shows the current one)",
198
+ handler: async (_args, ctx) => {
199
+ await pickReviewModel(ctx, getReviewStore());
200
+ },
201
+ });
202
+
203
+ // Footer indicator: muted "review" while a review model is configured.
204
+ let theme: Theme | undefined;
205
+ let latestCtx: ExtensionContext | undefined;
206
+
207
+ const updateFooterStatus = (ctx: ExtensionContext): void => {
208
+ ctx.ui.setStatus(
209
+ REVIEW_STATUS_KEY,
210
+ getReviewStore().getModel() && theme
211
+ ? theme.fg("muted", "review")
212
+ : undefined,
213
+ );
214
+ };
215
+
216
+ pi.on("session_start", (_event, ctx) => {
217
+ latestCtx = ctx;
218
+ try {
219
+ const t = ctx.ui.theme;
220
+ if (t) {
221
+ theme = t;
222
+ }
223
+ } catch {
224
+ // No theme in this mode — footer indicator stays off.
225
+ }
226
+ updateFooterStatus(ctx);
227
+ });
228
+
229
+ getReviewStore().subscribe(() => {
230
+ if (latestCtx) updateFooterStatus(latestCtx);
231
+ });
232
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * review feature — third-party review prompt.
3
+ *
4
+ * The review model keeps FULL tool access: this is "check AND fix", not a
5
+ * read-only audit (confirmed with the user). The prompt is sent as a user
6
+ * message via pi.sendUserMessage(), so the review turn — including any fixes
7
+ * it makes — stays in the current session transcript right next to the work
8
+ * under review.
9
+ *
10
+ * The three focus points the user asked for (本轮需求实现 / 是否有遗漏 /
11
+ * 是否有错误) are spelled out verbatim in the prompt.
12
+ */
13
+
14
+ /** Trigger words for the bare-message form of the third-review command. */
15
+ export const TRIGGER_WORDS: ReadonlySet<string> = new Set([
16
+ "third-review",
17
+ "third review",
18
+ ]);
19
+
20
+ /** Whether the input text is exactly a third-review trigger (case/space-insensitive). */
21
+ export function isReviewTrigger(text: string): boolean {
22
+ return TRIGGER_WORDS.has(text.trim().toLowerCase());
23
+ }
24
+
25
+ export const REVIEW_PROMPT = `请以第三方代码评审的身份,对本次会话中刚完成的开发任务进行 Review。
26
+
27
+ 本轮需求、开发过程和代码改动都在当前会话上下文中,请结合上下文,并用 read/grep 等工具实际检查仓库代码,不要凭空猜测。
28
+
29
+ 请重点检查以下三点(逐项给出结论):
30
+ 1. 本轮需求实现:本轮需求是否已完整、正确地实现?实现与需求描述是否一致?
31
+ 2. 是否有遗漏:需求中是否有遗漏的功能点、边界情况、异常处理、兼容性、测试等?
32
+ 3. 是否有错误:代码中是否存在逻辑错误、潜在 bug、类型/编译错误、安全与性能隐患等?
33
+
34
+ 要求:
35
+ - 对确认的问题直接修复(write/edit),并简要说明改了什么、为什么改;
36
+ - 无法确认或不宜直接改的问题,列出:位置(文件/行)、问题描述、建议方案;
37
+ - 最后输出评审报告:发现问题清单(已修复 / 未修复)、修复说明、总体结论(通过 / 需修改)与剩余风险。`;
@@ -0,0 +1,443 @@
1
+ /**
2
+ * review feature — unit tests.
3
+ *
4
+ * Covers the logic behind the third-review command:
5
+ * - trigger words (isReviewTrigger): exact bare-message match only
6
+ * - store: set/get persistence round-trip + corrupted-file fallback
7
+ * - modelKey / resolveConfiguredModel: provider/id resolution, incl. ids
8
+ * that themselves contain "/" (e.g. openrouter/openai/gpt-5)
9
+ * - createReviewRunner: busy guard, configured-model path, no-config /
10
+ * stale-config select path (persist + run), cancel path, setModel
11
+ * failure, reentrancy
12
+ *
13
+ * The runner reads the shared store via getReviewStore(); each test gets an
14
+ * isolated store backed by a temp file via setSharedReviewStore(). pi and
15
+ * ctx are minimal fakes (vi.fn()).
16
+ */
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type {
21
+ ExtensionAPI,
22
+ ExtensionContext,
23
+ ModelRegistry,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import type { Api, Model } from "@earendil-works/pi-ai";
26
+ import {
27
+ afterAll,
28
+ beforeAll,
29
+ beforeEach,
30
+ describe,
31
+ expect,
32
+ it,
33
+ vi,
34
+ } from "vitest";
35
+ import {
36
+ createReviewRunner,
37
+ pickReviewModel,
38
+ resolveConfiguredModel,
39
+ } from "./index.js";
40
+ import { REVIEW_PROMPT, isReviewTrigger } from "./prompt.js";
41
+ import {
42
+ ReviewStore,
43
+ getReviewStore,
44
+ modelKey,
45
+ setSharedReviewStore,
46
+ } from "./store.js";
47
+
48
+ // ─── fakes & setup ─────────────────────────────────────────────
49
+
50
+ let tmpDir: string;
51
+ let storeSeq = 0;
52
+
53
+ beforeAll(() => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), "pi-toolkits-review-"));
55
+ });
56
+
57
+ beforeEach(() => {
58
+ storeSeq += 1;
59
+ setSharedReviewStore(
60
+ new ReviewStore(join(tmpDir, `review-${storeSeq}.json`)),
61
+ );
62
+ });
63
+
64
+ afterAll(() => {
65
+ rmSync(tmpDir, { recursive: true, force: true });
66
+ });
67
+
68
+ function freshStoreFile(): string {
69
+ storeSeq += 1;
70
+ return join(tmpDir, `review-${storeSeq}.json`);
71
+ }
72
+
73
+ function fakeModel(provider: string, id: string, name = id): Model<Api> {
74
+ return {
75
+ id,
76
+ name,
77
+ provider,
78
+ api: "anthropic-messages",
79
+ baseUrl: "https://api.anthropic.com",
80
+ reasoning: false,
81
+ input: ["text"],
82
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
+ contextWindow: 200000,
84
+ maxTokens: 8192,
85
+ };
86
+ }
87
+
88
+ const MODELS: Model<Api>[] = [
89
+ fakeModel("anthropic", "claude-sonnet-4", "Claude Sonnet 4"),
90
+ fakeModel("anthropic", "claude-opus-4", "Claude Opus 4"),
91
+ fakeModel("openrouter", "openai/gpt-5", "GPT-5"),
92
+ ];
93
+
94
+ function fakeCtx(
95
+ overrides: {
96
+ idle?: boolean;
97
+ models?: Model<Api>[];
98
+ mode?: string;
99
+ hasUI?: boolean;
100
+ selectResult?: string | undefined;
101
+ } = {},
102
+ ) {
103
+ const notify = vi.fn();
104
+ const select = vi.fn(async () => overrides.selectResult);
105
+ const ctx = {
106
+ mode: overrides.mode ?? "tui",
107
+ hasUI: overrides.hasUI ?? true,
108
+ isIdle: () => overrides.idle ?? true,
109
+ modelRegistry: {
110
+ getAvailable: () => overrides.models ?? MODELS,
111
+ } as unknown as ModelRegistry,
112
+ ui: { notify, select },
113
+ } as unknown as ExtensionContext;
114
+ return { ctx, notify, select };
115
+ }
116
+
117
+ function fakePi(switched = true) {
118
+ const setModel = vi.fn(async () => switched);
119
+ const sendUserMessage = vi.fn();
120
+ const pi = { setModel, sendUserMessage } as unknown as ExtensionAPI;
121
+ return { pi, setModel, sendUserMessage };
122
+ }
123
+
124
+ // ─── trigger words ─────────────────────────────────────────────
125
+
126
+ describe("isReviewTrigger", () => {
127
+ it("matches the bare trigger words case/space-insensitively", () => {
128
+ expect(isReviewTrigger("third-review")).toBe(true);
129
+ expect(isReviewTrigger(" Third-Review ")).toBe(true);
130
+ expect(isReviewTrigger("third review")).toBe(true);
131
+ expect(isReviewTrigger("THIRD REVIEW")).toBe(true);
132
+ });
133
+
134
+ it("rejects non-trigger text (no prefix/suffix matching)", () => {
135
+ expect(isReviewTrigger("third-review now")).toBe(false);
136
+ expect(isReviewTrigger("third_review")).toBe(false);
137
+ expect(isReviewTrigger("thirdreview")).toBe(false);
138
+ expect(isReviewTrigger("/third-review")).toBe(false);
139
+ expect(isReviewTrigger("")).toBe(false);
140
+ expect(isReviewTrigger("review the code please")).toBe(false);
141
+ });
142
+ });
143
+
144
+ // ─── model key & resolution ────────────────────────────────────
145
+
146
+ describe("modelKey", () => {
147
+ it("joins provider and id with a slash", () => {
148
+ expect(modelKey({ provider: "anthropic", id: "claude-sonnet-4" })).toBe(
149
+ "anthropic/claude-sonnet-4",
150
+ );
151
+ });
152
+
153
+ it("keeps slashes inside the id (openrouter-style ids)", () => {
154
+ expect(modelKey({ provider: "openrouter", id: "openai/gpt-5" })).toBe(
155
+ "openrouter/openai/gpt-5",
156
+ );
157
+ });
158
+ });
159
+
160
+ describe("resolveConfiguredModel", () => {
161
+ it("resolves a configured key to the available model", () => {
162
+ const { ctx } = fakeCtx();
163
+ expect(resolveConfiguredModel(ctx, "anthropic/claude-sonnet-4")).toBe(
164
+ MODELS[0],
165
+ );
166
+ });
167
+
168
+ it("resolves ids that contain a slash (split on the FIRST slash)", () => {
169
+ const { ctx } = fakeCtx();
170
+ expect(resolveConfiguredModel(ctx, "openrouter/openai/gpt-5")).toBe(
171
+ MODELS[2],
172
+ );
173
+ });
174
+
175
+ it("returns undefined when nothing is configured", () => {
176
+ const { ctx } = fakeCtx();
177
+ expect(resolveConfiguredModel(ctx, undefined)).toBeUndefined();
178
+ });
179
+
180
+ it("returns undefined when the configured model is gone from the registry", () => {
181
+ const { ctx } = fakeCtx();
182
+ // e.g. deleted from pi's models.json after being configured
183
+ expect(
184
+ resolveConfiguredModel(ctx, "anthropic/claude-opus-4-5"),
185
+ ).toBeUndefined();
186
+ });
187
+
188
+ it("returns undefined for malformed keys", () => {
189
+ const { ctx } = fakeCtx();
190
+ expect(resolveConfiguredModel(ctx, "no-slash")).toBeUndefined();
191
+ expect(resolveConfiguredModel(ctx, "anthropic/")).toBeUndefined();
192
+ expect(resolveConfiguredModel(ctx, "/claude-sonnet-4")).toBeUndefined();
193
+ });
194
+ });
195
+
196
+ // ─── store ─────────────────────────────────────────────────────
197
+
198
+ describe("ReviewStore", () => {
199
+ it("set/get round-trips within one instance", () => {
200
+ const store = new ReviewStore(freshStoreFile());
201
+ expect(store.getModel()).toBeUndefined();
202
+ store.setModel("anthropic/claude-sonnet-4");
203
+ expect(store.getModel()).toBe("anthropic/claude-sonnet-4");
204
+ });
205
+
206
+ it("persists across instances (file round-trip)", () => {
207
+ const file = freshStoreFile();
208
+ const a = new ReviewStore(file);
209
+ a.setModel("openrouter/openai/gpt-5");
210
+ const b = new ReviewStore(file);
211
+ expect(b.getModel()).toBe("openrouter/openai/gpt-5");
212
+ });
213
+
214
+ it("clears the configured model with undefined", () => {
215
+ const store = new ReviewStore(freshStoreFile());
216
+ store.setModel("anthropic/claude-sonnet-4");
217
+ store.setModel(undefined);
218
+ expect(store.getModel()).toBeUndefined();
219
+ });
220
+
221
+ it("falls back to unset on a corrupted file", () => {
222
+ const file = freshStoreFile();
223
+ try {
224
+ writeFileSync(file, "{ not json", "utf8");
225
+ } catch {
226
+ // A failed write still leaves the store to hit the read-error path.
227
+ }
228
+ const store = new ReviewStore(file);
229
+ expect(store.getModel()).toBeUndefined();
230
+ });
231
+
232
+ it("ignores a stored value without a slash", () => {
233
+ const file = freshStoreFile();
234
+ writeFileSync(file, JSON.stringify({ model: "no-slash-here" }), "utf8");
235
+ expect(new ReviewStore(file).getModel()).toBeUndefined();
236
+ });
237
+
238
+ it("notifies subscribers on set", () => {
239
+ const store = new ReviewStore(freshStoreFile());
240
+ const listener = vi.fn();
241
+ store.subscribe(listener);
242
+ store.setModel("anthropic/claude-sonnet-4");
243
+ expect(listener).toHaveBeenCalledTimes(1);
244
+ });
245
+ });
246
+
247
+ // ─── pickReviewModel ───────────────────────────────────────────
248
+
249
+ describe("pickReviewModel", () => {
250
+ it("shows the available models and persists the pick", async () => {
251
+ const { ctx, select } = fakeCtx({
252
+ selectResult: "GPT-5 (openrouter/openai/gpt-5)",
253
+ });
254
+ const store = new ReviewStore(freshStoreFile());
255
+ const picked = await pickReviewModel(ctx, store);
256
+ expect(picked).toBe(MODELS[2]);
257
+ expect(select).toHaveBeenCalledWith("选择评审模型 (third-review)", [
258
+ "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
259
+ "Claude Opus 4 (anthropic/claude-opus-4)",
260
+ "GPT-5 (openrouter/openai/gpt-5)",
261
+ ]);
262
+ expect(store.getModel()).toBe("openrouter/openai/gpt-5");
263
+ });
264
+
265
+ it("returns undefined and does not persist when cancelled", async () => {
266
+ const { ctx, select } = fakeCtx({ selectResult: undefined });
267
+ const store = new ReviewStore(freshStoreFile());
268
+ expect(await pickReviewModel(ctx, store)).toBeUndefined();
269
+ expect(select).toHaveBeenCalledTimes(1);
270
+ expect(store.getModel()).toBeUndefined();
271
+ });
272
+
273
+ it("fails gracefully when no models are available", async () => {
274
+ const { ctx, notify, select } = fakeCtx({ models: [] });
275
+ const store = new ReviewStore(freshStoreFile());
276
+ expect(await pickReviewModel(ctx, store)).toBeUndefined();
277
+ expect(select).not.toHaveBeenCalled();
278
+ expect(notify).toHaveBeenCalledWith(
279
+ expect.stringContaining("没有可用模型"),
280
+ "error",
281
+ );
282
+ });
283
+
284
+ it("refuses when no dialog-capable UI is available", async () => {
285
+ const { ctx, notify, select } = fakeCtx({ hasUI: false });
286
+ const store = new ReviewStore(freshStoreFile());
287
+ expect(await pickReviewModel(ctx, store)).toBeUndefined();
288
+ expect(select).not.toHaveBeenCalled();
289
+ expect(notify).toHaveBeenCalledWith(
290
+ expect.stringContaining("不支持模型选择对话框"),
291
+ "error",
292
+ );
293
+ });
294
+
295
+ it("works in rpc mode (dialogs are available there)", async () => {
296
+ const { ctx, select } = fakeCtx({
297
+ mode: "rpc",
298
+ selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
299
+ });
300
+ const store = new ReviewStore(freshStoreFile());
301
+ const picked = await pickReviewModel(ctx, store);
302
+ expect(picked).toBe(MODELS[1]);
303
+ expect(select).toHaveBeenCalledTimes(1);
304
+ expect(store.getModel()).toBe("anthropic/claude-opus-4");
305
+ });
306
+
307
+ it("shows the current config in the picker title", async () => {
308
+ const store = new ReviewStore(freshStoreFile());
309
+ store.setModel("anthropic/claude-sonnet-4");
310
+ const { ctx, select } = fakeCtx({ selectResult: undefined });
311
+ await pickReviewModel(ctx, store);
312
+ expect(select).toHaveBeenCalledWith(
313
+ "选择评审模型 (当前: anthropic/claude-sonnet-4)",
314
+ expect.any(Array),
315
+ );
316
+ // Cancelling a change keeps the existing config.
317
+ expect(store.getModel()).toBe("anthropic/claude-sonnet-4");
318
+ });
319
+ });
320
+
321
+ // ─── createReviewRunner ────────────────────────────────────────
322
+
323
+ describe("createReviewRunner", () => {
324
+ it("refuses to run while the agent is streaming", async () => {
325
+ const { pi } = fakePi();
326
+ const { ctx, notify } = fakeCtx({ idle: false });
327
+ await createReviewRunner(pi)(ctx);
328
+ expect(notify).toHaveBeenCalledWith(
329
+ expect.stringContaining("等待完成"),
330
+ "warning",
331
+ );
332
+ expect(pi.setModel).not.toHaveBeenCalled();
333
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
334
+ });
335
+
336
+ it("switches to the configured model and sends the review prompt", async () => {
337
+ getReviewStore().setModel("anthropic/claude-sonnet-4");
338
+ const { pi, setModel, sendUserMessage } = fakePi();
339
+ const { ctx, notify } = fakeCtx();
340
+ await createReviewRunner(pi)(ctx);
341
+ expect(setModel).toHaveBeenCalledWith(MODELS[0]);
342
+ expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
343
+ expect(notify).toHaveBeenCalledWith(
344
+ expect.stringContaining("已切换到评审模型"),
345
+ "info",
346
+ );
347
+ });
348
+
349
+ it("picks, persists and runs when no model is configured", async () => {
350
+ const { pi, setModel, sendUserMessage } = fakePi();
351
+ const { ctx, notify, select } = fakeCtx({
352
+ selectResult: "GPT-5 (openrouter/openai/gpt-5)",
353
+ });
354
+ await createReviewRunner(pi)(ctx);
355
+ expect(getReviewStore().getModel()).toBe("openrouter/openai/gpt-5");
356
+ expect(setModel).toHaveBeenCalledWith(MODELS[2]);
357
+ expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
358
+ expect(select).toHaveBeenCalledTimes(1);
359
+ expect(notify).toHaveBeenCalledWith(
360
+ expect.stringContaining("尚未配置评审模型"),
361
+ "warning",
362
+ );
363
+ });
364
+
365
+ it("re-picks, persists and runs when the configured model is unavailable", async () => {
366
+ getReviewStore().setModel("anthropic/claude-opus-4-5"); // deleted in pi
367
+ const { pi, setModel, sendUserMessage } = fakePi();
368
+ const { ctx, notify, select } = fakeCtx({
369
+ selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
370
+ });
371
+ await createReviewRunner(pi)(ctx);
372
+ expect(notify).toHaveBeenCalledWith(
373
+ expect.stringContaining("不存在或不可用"),
374
+ "warning",
375
+ );
376
+ expect(getReviewStore().getModel()).toBe("anthropic/claude-sonnet-4");
377
+ expect(setModel).toHaveBeenCalledWith(MODELS[0]);
378
+ expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
379
+ expect(select).toHaveBeenCalledTimes(1);
380
+ });
381
+
382
+ it("aborts when the user cancels the model pick", async () => {
383
+ const { pi, setModel, sendUserMessage } = fakePi();
384
+ const { ctx, select } = fakeCtx({ selectResult: undefined });
385
+ await createReviewRunner(pi)(ctx);
386
+ expect(select).toHaveBeenCalledTimes(1);
387
+ expect(setModel).not.toHaveBeenCalled();
388
+ expect(sendUserMessage).not.toHaveBeenCalled();
389
+ expect(getReviewStore().getModel()).toBeUndefined();
390
+ });
391
+
392
+ it("reports and aborts when switching to the review model fails", async () => {
393
+ getReviewStore().setModel("anthropic/claude-sonnet-4");
394
+ const { pi, setModel, sendUserMessage } = fakePi(false); // setModel → false
395
+ const { ctx, notify } = fakeCtx();
396
+ await createReviewRunner(pi)(ctx);
397
+ expect(setModel).toHaveBeenCalledWith(MODELS[0]);
398
+ expect(sendUserMessage).not.toHaveBeenCalled();
399
+ expect(notify).toHaveBeenCalledWith(
400
+ expect.stringContaining("无法切换到评审模型"),
401
+ "error",
402
+ );
403
+ });
404
+
405
+ it("serializes concurrent triggers (one review at a time)", async () => {
406
+ const { pi } = fakePi();
407
+ // First run blocks inside ui.select until we resolve it.
408
+ let release: ((v: string | undefined) => void) | undefined;
409
+ const select = vi.fn(
410
+ () => new Promise<string | undefined>((resolve) => (release = resolve)),
411
+ );
412
+ const ctx = {
413
+ mode: "tui",
414
+ hasUI: true,
415
+ isIdle: () => true,
416
+ modelRegistry: {
417
+ getAvailable: () => MODELS,
418
+ } as unknown as ModelRegistry,
419
+ ui: { notify: vi.fn(), select },
420
+ } as unknown as ExtensionContext;
421
+
422
+ const runner = createReviewRunner(pi);
423
+ const first = runner(ctx);
424
+ // Second trigger while the first is still picking — must be refused.
425
+ const notify2 = vi.fn();
426
+ const ctx2 = {
427
+ mode: "tui",
428
+ hasUI: true,
429
+ isIdle: () => true,
430
+ modelRegistry: { getAvailable: () => MODELS } as unknown as ModelRegistry,
431
+ ui: { notify: notify2, select: vi.fn(async () => undefined) },
432
+ } as unknown as ExtensionContext;
433
+ await runner(ctx2);
434
+ expect(notify2).toHaveBeenCalledWith(
435
+ expect.stringContaining("评审已在执行中"),
436
+ "warning",
437
+ );
438
+ release?.("Claude Sonnet 4 (anthropic/claude-sonnet-4)");
439
+ await first;
440
+ expect(pi.setModel).toHaveBeenCalledTimes(1);
441
+ expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
442
+ });
443
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * review feature — persisted review-model config store.
3
+ *
4
+ * State lives in `<agent-dir>/pi-toolkits-review.json` (agent-dir from
5
+ * getAgentDir(), same convention as the favorites store). The file holds a
6
+ * single model key:
7
+ *
8
+ * { "model": "anthropic/claude-sonnet-4" }
9
+ *
10
+ * Robustness mirrors the favorites store: corrupted/unreadable files fall
11
+ * back to "no model configured" with a console.warn; writes are atomic
12
+ * (tmp + rename) and never throw.
13
+ */
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ renameSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
+ import { REVIEW_FILE } from "./constants.js";
24
+
25
+ /** Model identity used as the review-model key: "provider/id". */
26
+ export function modelKey(model: { provider: string; id: string }): string {
27
+ return `${model.provider}/${model.id}`;
28
+ }
29
+
30
+ export class ReviewStore {
31
+ private model: string | undefined;
32
+ private readonly file: string;
33
+ private loaded = false;
34
+ private readonly listeners = new Set<() => void>();
35
+
36
+ constructor(file: string = join(getAgentDir(), REVIEW_FILE)) {
37
+ this.file = file;
38
+ }
39
+
40
+ private ensureLoaded(): void {
41
+ if (this.loaded) return;
42
+ this.loaded = true;
43
+ try {
44
+ if (!existsSync(this.file)) return;
45
+ const raw = readFileSync(this.file, "utf8");
46
+ const parsed = JSON.parse(raw) as { model?: unknown };
47
+ if (
48
+ parsed &&
49
+ typeof parsed.model === "string" &&
50
+ parsed.model.includes("/")
51
+ ) {
52
+ this.model = parsed.model;
53
+ }
54
+ } catch (error) {
55
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
56
+ console.warn(
57
+ "[pi-toolkits/review] Failed to load review config, starting unset:",
58
+ error instanceof Error ? error.message : String(error),
59
+ );
60
+ this.model = undefined;
61
+ }
62
+ }
63
+
64
+ /** Atomically persist (tmp + rename). Never throws. */
65
+ save(): void {
66
+ try {
67
+ const dir = dirname(this.file);
68
+ if (!existsSync(dir)) {
69
+ mkdirSync(dir, { recursive: true });
70
+ }
71
+ const tmp = `${this.file}.tmp`;
72
+ writeFileSync(
73
+ tmp,
74
+ `${JSON.stringify({ model: this.model ?? null }, null, 2)}\n`,
75
+ "utf8",
76
+ );
77
+ renameSync(tmp, this.file);
78
+ } catch (error) {
79
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
80
+ console.warn(
81
+ "[pi-toolkits/review] Failed to save review config:",
82
+ error instanceof Error ? error.message : String(error),
83
+ );
84
+ }
85
+ }
86
+
87
+ /** The configured review model key ("provider/id"), or undefined. */
88
+ getModel(): string | undefined {
89
+ this.ensureLoaded();
90
+ return this.model;
91
+ }
92
+
93
+ /** Set (or clear with undefined) the review model key and persist. */
94
+ setModel(key: string | undefined): void {
95
+ this.ensureLoaded();
96
+ this.model = key;
97
+ this.save();
98
+ this.notify();
99
+ }
100
+
101
+ subscribe(listener: () => void): () => void {
102
+ this.listeners.add(listener);
103
+ return () => {
104
+ this.listeners.delete(listener);
105
+ };
106
+ }
107
+
108
+ private notify(): void {
109
+ for (const listener of [...this.listeners]) {
110
+ try {
111
+ listener();
112
+ } catch {
113
+ // A broken listener must not break review config handling.
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Process-wide store shared by the runner and commands.
121
+ *
122
+ * Accessed via getReviewStore() (lazy) so tests can swap in an isolated
123
+ * store via setSharedReviewStore().
124
+ */
125
+ let sharedStore: ReviewStore | undefined;
126
+
127
+ export function getReviewStore(): ReviewStore {
128
+ if (!sharedStore) {
129
+ sharedStore = new ReviewStore();
130
+ }
131
+ return sharedStore;
132
+ }
133
+
134
+ /** Test hook — replace the shared store (e.g. with one backed by a temp file). */
135
+ export function setSharedReviewStore(store: ReviewStore): void {
136
+ sharedStore = store;
137
+ }
package/src/index.ts CHANGED
@@ -8,15 +8,18 @@
8
8
  * - ask — read-only Q&A mode (/ask)
9
9
  * - favorites — model favorites (ctrl+F/ctrl+J in /model, favorites-only cycling)
10
10
  * - stash — prompt stash (ctrl+alt+y swap / double-press stash-and-clear)
11
+ * - review — third-party code review with a configured review model (third-review)
11
12
  */
12
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
14
  import { registerAsk } from "./features/ask/index.js";
14
15
  import { registerFavorites } from "./features/favorites/index.js";
16
+ import { registerReview } from "./features/review/index.js";
15
17
  import { registerStash } from "./features/stash/index.js";
16
18
 
17
19
  export default function piToolkits(pi: ExtensionAPI): void {
18
20
  registerAsk(pi);
19
21
  registerFavorites(pi);
22
+ registerReview(pi);
20
23
  registerStash(pi);
21
24
  // registerXxx(pi); // future features
22
25
  }