@andares/pi-toolkits 0.1.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 +203 -24
- package/package.json +7 -3
- package/src/features/favorites/constants.ts +31 -0
- package/src/features/favorites/favorites.test.ts +437 -0
- package/src/features/favorites/index.ts +112 -0
- package/src/features/favorites/patch-cycle.ts +165 -0
- package/src/features/favorites/patch-selector.ts +300 -0
- package/src/features/favorites/store.ts +197 -0
- package/src/features/review/constants.ts +13 -0
- package/src/features/review/index.ts +232 -0
- package/src/features/review/prompt.ts +37 -0
- package/src/features/review/review.test.ts +443 -0
- package/src/features/review/store.ts +137 -0
- package/src/features/stash/index.ts +29 -0
- package/src/features/stash/stash.test.ts +101 -0
- package/src/features/stash/stash.ts +83 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stash feature — prompt stash.
|
|
3
|
+
*
|
|
4
|
+
* One hotkey (ctrl+alt+y) swaps the editor text with a single stashed
|
|
5
|
+
* prompt; a quick double-press stashes the current prompt and clears the
|
|
6
|
+
* editor (see stash.ts for the state machine and keybinding rationale).
|
|
7
|
+
*
|
|
8
|
+
* The shortcut is registered via pi.registerShortcut, which pi dispatches
|
|
9
|
+
* FIRST while the editor is focused (CustomEditor.handleInput →
|
|
10
|
+
* onExtensionShortcut) — exactly the prompt-input scenario. Selectors take
|
|
11
|
+
* focus away from the editor, so the key is inert while a selector is open,
|
|
12
|
+
* and it does not collide with any pi default binding.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { PromptStash, STASH_KEY } from "./stash.js";
|
|
16
|
+
|
|
17
|
+
export function registerStash(pi: ExtensionAPI): void {
|
|
18
|
+
const stash = new PromptStash();
|
|
19
|
+
|
|
20
|
+
pi.registerShortcut(STASH_KEY, {
|
|
21
|
+
description:
|
|
22
|
+
"Stash prompt: swap editor text with the stashed prompt; press twice quickly to stash and clear the editor",
|
|
23
|
+
handler: (ctx) => {
|
|
24
|
+
const current = ctx.ui.getEditorText();
|
|
25
|
+
const result = stash.press(current);
|
|
26
|
+
ctx.ui.setEditorText(result.text);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|