foliko 2.0.18 → 2.0.20

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.
@@ -0,0 +1,630 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const {
7
+ PlannerPlugin,
8
+ PlanStore,
9
+ PlanSerializer,
10
+ } = require('../../plugins/core/planner/index.js');
11
+
12
+ // ============================================================
13
+ // 测试夹具
14
+ // ============================================================
15
+ function makeTempDir() {
16
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'planner-test-'));
17
+ }
18
+
19
+ function makeFakeFramework(tempDir, sessionId = 'test_session_xxx') {
20
+ return {
21
+ getCwd: () => process.cwd(),
22
+ getExecutionContext: () => ({ sessionId }),
23
+ pluginManager: {
24
+ get: (name) => null,
25
+ },
26
+ _sessionContexts: new Map(),
27
+ };
28
+ }
29
+
30
+ function makePlugin(tempDir) {
31
+ const plugin = new PlannerPlugin({ dir: tempDir });
32
+ const fw = makeFakeFramework(tempDir);
33
+ plugin.install(fw);
34
+ plugin.start(fw);
35
+ return plugin;
36
+ }
37
+
38
+ // ============================================================
39
+ // PlanSerializer 单测
40
+ // ============================================================
41
+ describe('PlanSerializer', () => {
42
+ it('toMarkdown 生成有效 frontmatter 和 checkbox 步骤', () => {
43
+ const plan = {
44
+ planId: 'plan_x',
45
+ title: '测试计划',
46
+ task: '原始任务',
47
+ sessionId: 'sess_1',
48
+ status: 'in_progress',
49
+ totalSteps: 3,
50
+ completedSteps: 1,
51
+ failedSteps: 1,
52
+ createdAt: '2026-07-06T00:00:00.000Z',
53
+ updatedAt: '2026-07-06T00:01:00.000Z',
54
+ steps: [
55
+ { index: 0, name: '步骤1', description: '详细', status: 'completed' },
56
+ { index: 1, name: '步骤2', status: 'failed' },
57
+ { index: 2, name: '步骤3', status: 'pending' },
58
+ ],
59
+ };
60
+ const md = PlanSerializer.toMarkdown(plan);
61
+ expect(md).toMatch(/^---\n/);
62
+ expect(md).toContain('planId: "plan_x"');
63
+ expect(md).toContain('title: "测试计划"');
64
+ expect(md).toContain('- [x] 1. 步骤1 — 详细');
65
+ expect(md).toContain('- [!] 2. 步骤2');
66
+ expect(md).toContain('- [ ] 3. 步骤3');
67
+ });
68
+
69
+ it('fromMarkdown 解析 frontmatter 字段', () => {
70
+ const md = `---
71
+ planId: "plan_abc"
72
+ title: "标题"
73
+ status: "in_progress"
74
+ totalSteps: 2
75
+ completedSteps: 0
76
+ failedSteps: 0
77
+ createdAt: "2026-07-06T00:00:00.000Z"
78
+ updatedAt: "2026-07-06T00:00:00.000Z"
79
+ ---
80
+
81
+ # 标题
82
+
83
+ ## 任务描述
84
+ 测试任务
85
+
86
+ ## 计划步骤
87
+ - [x] 1. 步骤1 — 已完成
88
+ - [ ] 2. 步骤2
89
+
90
+ ## 执行日志
91
+ 暂无
92
+ `;
93
+ const plan = PlanSerializer.fromMarkdown(md);
94
+ expect(plan.planId).toBe('plan_abc');
95
+ expect(plan.title).toBe('标题');
96
+ expect(plan.status).toBe('in_progress');
97
+ expect(plan.totalSteps).toBe(2);
98
+ expect(plan.steps).toHaveLength(2);
99
+ expect(plan.steps[0].status).toBe('completed');
100
+ expect(plan.steps[1].status).toBe('pending');
101
+ });
102
+
103
+ it('round-trip: toMarkdown → fromMarkdown 保留状态', () => {
104
+ const plan = {
105
+ planId: 'plan_rt',
106
+ title: 'Round-Trip',
107
+ task: '',
108
+ sessionId: null,
109
+ status: 'in_progress',
110
+ totalSteps: 2,
111
+ completedSteps: 1,
112
+ failedSteps: 0,
113
+ createdAt: '2026-07-06T10:00:00.000Z',
114
+ updatedAt: '2026-07-06T10:01:00.000Z',
115
+ steps: [
116
+ { index: 0, name: 'A', description: null, status: 'completed' },
117
+ { index: 1, name: 'B', description: null, status: 'pending' },
118
+ ],
119
+ };
120
+ const md = PlanSerializer.toMarkdown(plan);
121
+ const restored = PlanSerializer.fromMarkdown(md);
122
+ expect(restored.status).toBe('in_progress');
123
+ expect(restored.steps).toHaveLength(2);
124
+ expect(restored.steps[0].status).toBe('completed');
125
+ expect(restored.steps[1].status).toBe('pending');
126
+ });
127
+
128
+ it('frontmatter 含双引号转义', () => {
129
+ const plan = {
130
+ planId: 'p1', title: '标题"含引号', status: 'in_progress',
131
+ totalSteps: 1, completedSteps: 0, failedSteps: 0,
132
+ steps: [{ index: 0, name: 's', status: 'pending' }],
133
+ createdAt: 'x', updatedAt: 'y', sessionId: null, task: '',
134
+ };
135
+ const md = PlanSerializer.toMarkdown(plan);
136
+ expect(md).toContain('title: "标题\\"含引号"');
137
+ });
138
+ });
139
+
140
+ // ============================================================
141
+ // PlanStore 单测
142
+ // ============================================================
143
+ describe('PlanStore', () => {
144
+ let tempDir, store;
145
+ beforeEach(() => {
146
+ tempDir = makeTempDir();
147
+ store = new PlanStore(tempDir);
148
+ });
149
+ afterEach(() => {
150
+ fs.rmSync(tempDir, { recursive: true, force: true });
151
+ });
152
+
153
+ it('save → load 保留 frontmatter 字段', () => {
154
+ const plan = {
155
+ planId: 'p1', title: 't', task: '', sessionId: null,
156
+ status: 'in_progress', totalSteps: 1, completedSteps: 0, failedSteps: 0,
157
+ createdAt: 'x', updatedAt: 'y',
158
+ steps: [{ index: 0, name: 's', status: 'pending' }],
159
+ };
160
+ store.save(plan);
161
+ expect(fs.existsSync(path.join(tempDir, 'p1.md'))).toBe(true);
162
+ const loaded = store.load('p1');
163
+ expect(loaded.planId).toBe('p1');
164
+ expect(loaded.steps).toHaveLength(1);
165
+ });
166
+
167
+ it('原子写:save 中途失败不应留下半残 .md', () => {
168
+ const plan = {
169
+ planId: 'p_atomic', title: 't', task: '', sessionId: null,
170
+ status: 'in_progress', totalSteps: 1, completedSteps: 0, failedSteps: 0,
171
+ createdAt: 'x', updatedAt: 'y',
172
+ steps: [{ index: 0, name: 's', status: 'pending' }],
173
+ };
174
+ let callCount = 0;
175
+ const origWrite = fs.writeFileSync;
176
+ fs.writeFileSync = (p, data, ...args) => {
177
+ callCount++;
178
+ if (p.endsWith('.tmp') && callCount === 1) {
179
+ throw new Error('disk full');
180
+ }
181
+ return origWrite(p, data, ...args);
182
+ };
183
+ try {
184
+ expect(() => store.save(plan)).toThrow('disk full');
185
+ } finally {
186
+ fs.writeFileSync = origWrite;
187
+ }
188
+ expect(fs.existsSync(path.join(tempDir, 'p_atomic.md'))).toBe(false);
189
+ });
190
+
191
+ it('list 按 updatedAt 倒序', () => {
192
+ const basePlan = {
193
+ title: 't', task: '', sessionId: null, status: 'in_progress',
194
+ totalSteps: 1, completedSteps: 0, failedSteps: 0,
195
+ steps: [{ index: 0, name: 's', status: 'pending' }],
196
+ };
197
+ store.save({ ...basePlan, planId: 'p_old', updatedAt: '2026-07-01T00:00:00.000Z', createdAt: '2026-07-01T00:00:00.000Z' });
198
+ store.save({ ...basePlan, planId: 'p_new', updatedAt: '2026-07-06T00:00:00.000Z', createdAt: '2026-07-06T00:00:00.000Z' });
199
+ const list = store.list();
200
+ expect(list[0].planId).toBe('p_new');
201
+ expect(list[1].planId).toBe('p_old');
202
+ });
203
+
204
+ it('delete 清理文件', () => {
205
+ const plan = {
206
+ planId: 'p_del', title: 't', task: '', sessionId: null,
207
+ status: 'in_progress', totalSteps: 1, completedSteps: 0, failedSteps: 0,
208
+ createdAt: 'x', updatedAt: 'y',
209
+ steps: [{ index: 0, name: 's', status: 'pending' }],
210
+ };
211
+ store.save(plan);
212
+ expect(store.delete('p_del')).toBe(true);
213
+ expect(store.exists('p_del')).toBe(false);
214
+ });
215
+ });
216
+
217
+ // ============================================================
218
+ // PlannerPlugin 集成测试
219
+ // ============================================================
220
+ describe('PlannerPlugin', () => {
221
+ let tempDir, plugin;
222
+ beforeEach(() => {
223
+ tempDir = makeTempDir();
224
+ plugin = makePlugin(tempDir);
225
+ });
226
+ afterEach(() => {
227
+ fs.rmSync(tempDir, { recursive: true, force: true });
228
+ });
229
+
230
+ it('plan_create 持久化 .md 并返回 planId', async () => {
231
+ const r = await plugin._createPlan({
232
+ title: '测试',
233
+ task: 'do something',
234
+ steps: [{ name: 's1' }, { name: 's2' }],
235
+ });
236
+ expect(r.success).toBe(true);
237
+ expect(r.planId).toMatch(/^plan_/);
238
+ expect(fs.existsSync(r.markdownPath)).toBe(true);
239
+ expect(r.stepsCount).toBe(2);
240
+ });
241
+
242
+ it('plan_create 拒绝空 steps', async () => {
243
+ const r = await plugin._createPlan({ title: 't', task: '', steps: [] });
244
+ expect(r.success).toBe(false);
245
+ });
246
+
247
+ it('plan_create 拒绝循环引用(step.tool === plan_create)', async () => {
248
+ const r = await plugin._createPlan({
249
+ title: 't', task: '',
250
+ steps: [{ name: 'loop', tool: 'plan_create' }],
251
+ });
252
+ expect(r.success).toBe(false);
253
+ expect(r.error).toMatch(/循环/);
254
+ });
255
+
256
+ it('plan_execute_step 推进状态、自动激活下一步', async () => {
257
+ const r = await plugin._createPlan({
258
+ title: '推进测试', task: '',
259
+ steps: [{ name: 's1' }, { name: 's2' }, { name: 's3' }],
260
+ });
261
+ const r1 = await plugin._executeStep({
262
+ planId: r.planId, stepIndex: 0, status: 'completed',
263
+ tool: 'read', args: { path: '/tmp' }, durationMs: 100,
264
+ });
265
+ expect(r1.success).toBe(true);
266
+ expect(r1.progress).toBe('1/3');
267
+ expect(r1.nextStep).toBe(1);
268
+ expect(r1.attempts).toBe(1);
269
+ expect(r1.isRetry).toBe(false);
270
+
271
+ const r2 = await plugin._executeStep({
272
+ planId: r.planId, stepIndex: 1, status: 'completed',
273
+ });
274
+ expect(r2.nextStep).toBe(2);
275
+ });
276
+
277
+ it('★ retry 时清空旧 error 并追加日志历史(修复 bug)', async () => {
278
+ const r = await plugin._createPlan({
279
+ title: 'retry 测试', task: '',
280
+ steps: [{ name: 's1' }, { name: 's2' }],
281
+ });
282
+ // 第一次失败
283
+ await plugin._executeStep({
284
+ planId: r.planId, stepIndex: 0, status: 'failed',
285
+ tool: 'edit_file', error: '文件不存在',
286
+ });
287
+ const plan1 = plugin._loadPlan(r.planId);
288
+ expect(plan1.steps[0].status).toBe('failed');
289
+ expect(plan1.steps[0].error).toBe('文件不存在');
290
+ expect(plan1.steps[0].attempts).toBe(1);
291
+ expect(plan1.steps[0].log).toContain('尝试 1');
292
+ expect(plan1.steps[0].log).toContain('错误: 文件不存在');
293
+
294
+ // 第二次重试:成功(不传 error)
295
+ const r2 = await plugin._executeStep({
296
+ planId: r.planId, stepIndex: 0, status: 'completed',
297
+ tool: 'edit_file', args: { path: '/x' }, durationMs: 50,
298
+ });
299
+ expect(r2.attempts).toBe(2);
300
+ expect(r2.isRetry).toBe(true);
301
+ const plan2 = plugin._loadPlan(r.planId);
302
+ expect(plan2.steps[0].status).toBe('completed');
303
+ expect(plan2.steps[0].error).toBeNull(); // ★ 关键:旧 error 已被清空
304
+ expect(plan2.steps[0].note).toBeNull();
305
+ expect(plan2.completedSteps).toBe(1);
306
+ expect(plan2.failedSteps).toBe(0);
307
+
308
+ // ★ 日志保留两次尝试的完整历史
309
+ expect(plan2.steps[0].log).toContain('尝试 1');
310
+ expect(plan2.steps[0].log).toContain('尝试 2');
311
+ expect(plan2.steps[0].log).toContain('错误: 文件不存在'); // 第一次失败的记录保留
312
+ expect(plan2.steps[0].log).toContain('completed'); // 第二次成功
313
+
314
+ // 重新加载磁盘验证持久化也对
315
+ const plugin2 = makePlugin(tempDir);
316
+ const reloaded = plugin2._loadPlan(r.planId);
317
+ expect(reloaded.steps[0].attempts).toBe(2);
318
+ expect(reloaded.steps[0].log).toContain('尝试 1');
319
+ expect(reloaded.steps[0].log).toContain('尝试 2');
320
+
321
+ // 磁盘 markdown 也要有两次尝试
322
+ const md = fs.readFileSync(plugin._store._toFilePath(r.planId), 'utf-8');
323
+ expect(md).toMatch(/尝试 1/);
324
+ expect(md).toMatch(/尝试 2/);
325
+ });
326
+
327
+ it('★ retry 时清空旧 result(不残留上次结果)', async () => {
328
+ const r = await plugin._createPlan({
329
+ title: 't', task: '', steps: [{ name: 's1' }],
330
+ });
331
+ await plugin._executeStep({
332
+ planId: r.planId, stepIndex: 0, status: 'completed',
333
+ tool: 'read', result: { oldData: 'should be cleared' },
334
+ });
335
+ let plan = plugin._loadPlan(r.planId);
336
+ expect(plan.steps[0].result).toEqual({ oldData: 'should be cleared' });
337
+
338
+ // retry 不传 result → 清空
339
+ await plugin._executeStep({
340
+ planId: r.planId, stepIndex: 0, status: 'in_progress',
341
+ });
342
+ plan = plugin._loadPlan(r.planId);
343
+ expect(plan.steps[0].result).toBeNull();
344
+ });
345
+
346
+ it('plan_execute_step 越界 stepIndex 返回 error', async () => {
347
+ const r = await plugin._createPlan({
348
+ title: 't', task: '', steps: [{ name: 's1' }],
349
+ });
350
+ const r2 = await plugin._executeStep({
351
+ planId: r.planId, stepIndex: 99, status: 'completed',
352
+ });
353
+ expect(r2.success).toBe(false);
354
+ });
355
+
356
+ it('plan_execute_step 连续 2 步失败返回 shouldAbort', async () => {
357
+ const r = await plugin._createPlan({
358
+ title: 't', task: '',
359
+ steps: [{ name: 's1' }, { name: 's2' }, { name: 's3' }],
360
+ });
361
+ await plugin._executeStep({ planId: r.planId, stepIndex: 0, status: 'failed', error: 'a' });
362
+ const r2 = await plugin._executeStep({ planId: r.planId, stepIndex: 1, status: 'failed', error: 'b' });
363
+ expect(r2.shouldAbort).toBe(true);
364
+ expect(r2.abortReason).toBeTruthy();
365
+ });
366
+
367
+ it('plan_complete 仅在所有步骤都完成时成功', async () => {
368
+ const r = await plugin._createPlan({
369
+ title: 't', task: '', steps: [{ name: 's1' }, { name: 's2' }],
370
+ });
371
+ const rFail = await plugin._completePlan({ planId: r.planId });
372
+ expect(rFail.success).toBe(false);
373
+
374
+ await plugin._executeStep({ planId: r.planId, stepIndex: 0, status: 'completed' });
375
+ await plugin._executeStep({ planId: r.planId, stepIndex: 1, status: 'completed' });
376
+ const rOK = await plugin._completePlan({ planId: r.planId, summary: '搞定' });
377
+ expect(rOK.success).toBe(true);
378
+ expect(rOK.status).toBe('completed');
379
+ });
380
+
381
+ it('plan_continue 从第一个未完成步骤恢复', async () => {
382
+ const r = await plugin._createPlan({
383
+ title: 't', task: '',
384
+ steps: [{ name: 's1' }, { name: 's2' }, { name: 's3' }],
385
+ });
386
+ await plugin._executeStep({ planId: r.planId, stepIndex: 0, status: 'completed' });
387
+
388
+ const plugin2 = makePlugin(tempDir);
389
+ const rCont = await plugin2._continuePlan({ planId: r.planId });
390
+ expect(rCont.success).toBe(true);
391
+ expect(rCont.resumableFrom).toBe(1);
392
+ expect(rCont.nextStepName).toBe('s2');
393
+ });
394
+
395
+ it('plan_continue 已完成计划返回 error', async () => {
396
+ const r = await plugin._createPlan({
397
+ title: 't', task: '', steps: [{ name: 's1' }],
398
+ });
399
+ await plugin._executeStep({ planId: r.planId, stepIndex: 0, status: 'completed' });
400
+ await plugin._completePlan({ planId: r.planId });
401
+ const rCont = await plugin._continuePlan({ planId: r.planId });
402
+ expect(rCont.success).toBe(false);
403
+ });
404
+
405
+ it('plan_fail 设置 status=failed', async () => {
406
+ const r = await plugin._createPlan({
407
+ title: 't', task: '', steps: [{ name: 's1' }],
408
+ });
409
+ const r2 = await plugin._failPlan({ planId: r.planId, reason: '测试中止' });
410
+ expect(r2.success).toBe(true);
411
+ const plan = plugin._loadPlan(r.planId);
412
+ expect(plan.status).toBe('failed');
413
+ });
414
+
415
+ it('plan_delete 删除文件并清理内存索引', async () => {
416
+ const r = await plugin._createPlan({
417
+ title: 't', task: '', steps: [{ name: 's1' }],
418
+ });
419
+ expect(plugin._index.has(r.planId)).toBe(true);
420
+ const r2 = await plugin._deletePlan({ planId: r.planId });
421
+ expect(r2.success).toBe(true);
422
+ expect(plugin._index.has(r.planId)).toBe(false);
423
+ expect(fs.existsSync(plugin._store._toFilePath(r.planId))).toBe(false);
424
+ });
425
+
426
+ it('plan_list 列出所有/按状态过滤', async () => {
427
+ const a = await plugin._createPlan({ title: 'a', task: '', steps: [{ name: 'x' }] });
428
+ const b = await plugin._createPlan({ title: 'b', task: '', steps: [{ name: 'x' }] });
429
+ await plugin._executeStep({ planId: a.planId, stepIndex: 0, status: 'completed' });
430
+ await plugin._completePlan({ planId: a.planId });
431
+
432
+ const all = await plugin._listPlans({});
433
+ expect(all.count).toBe(2);
434
+
435
+ const completed = await plugin._listPlans({ status: 'completed' });
436
+ expect(completed.count).toBe(1);
437
+ expect(completed.plans[0].title).toBe('a');
438
+
439
+ const inProgress = await plugin._listPlans({ status: 'in_progress' });
440
+ expect(inProgress.count).toBe(1);
441
+ expect(inProgress.plans[0].title).toBe('b');
442
+ });
443
+
444
+ it('plan_show 返回 markdown 文本', async () => {
445
+ const r = await plugin._createPlan({
446
+ title: 'show-test', task: '',
447
+ steps: [{ name: 's1' }, { name: 's2' }],
448
+ });
449
+ const r2 = await plugin._showPlan({ planId: r.planId });
450
+ expect(r2.success).toBe(true);
451
+ expect(r2.markdown).toContain('# show-test');
452
+ expect(r2.markdown).toContain('- [-] 1. s1');
453
+ expect(r2.markdown).toContain('- [ ] 2. s2');
454
+ });
455
+
456
+ it('进程重启:从磁盘加载所有计划到内存索引', async () => {
457
+ await plugin._createPlan({ title: 'p1', task: '', steps: [{ name: 'x' }] });
458
+ await plugin._createPlan({ title: 'p2', task: '', steps: [{ name: 'y' }] });
459
+
460
+ const plugin2 = new PlannerPlugin({ dir: tempDir });
461
+ plugin2.install(makeFakeFramework(tempDir));
462
+ expect(plugin2._index.size).toBe(2);
463
+ });
464
+
465
+ it('生成的 markdown 是可解析的(round-trip 一致)', async () => {
466
+ const r = await plugin._createPlan({
467
+ title: '一致性测试', task: '原任务',
468
+ steps: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
469
+ });
470
+ await plugin._executeStep({
471
+ planId: r.planId, stepIndex: 0, status: 'completed',
472
+ tool: 'read', args: { path: '/x' }, durationMs: 50,
473
+ });
474
+ await plugin._executeStep({
475
+ planId: r.planId, stepIndex: 1, status: 'failed',
476
+ tool: 'edit', error: '文件不存在',
477
+ });
478
+
479
+ const md = fs.readFileSync(plugin._store._toFilePath(r.planId), 'utf-8');
480
+ const restored = PlanSerializer.fromMarkdown(md);
481
+ expect(restored.status).toBe('in_progress');
482
+ expect(restored.completedSteps).toBe(1);
483
+ expect(restored.failedSteps).toBe(1);
484
+ expect(restored.steps).toHaveLength(3);
485
+ expect(restored.steps[0].status).toBe('completed');
486
+ expect(restored.steps[1].status).toBe('failed');
487
+ expect(restored.steps[2].status).toBe('pending');
488
+ });
489
+
490
+ // ============================================================
491
+ // planner-status 动态 prompt part(防止长上下文遗忘)
492
+ // ============================================================
493
+ describe('planner-status 动态 prompt', () => {
494
+ function makePluginWithPrompts(tempDir, sessionId = 'sess_42') {
495
+ const fw = {
496
+ getCwd: () => process.cwd(),
497
+ getExecutionContext: () => ({ sessionId }),
498
+ getCurrentSessionId: () => sessionId,
499
+ prompts: {
500
+ invalidate: (owner, name) => { fw._invalidateCalls = (fw._invalidateCalls || 0) + 1; },
501
+ },
502
+ pluginManager: { get: () => null },
503
+ _sessionContexts: new Map(),
504
+ };
505
+ fw._invalidateCalls = 0;
506
+ const p = new PlannerPlugin({ dir: tempDir });
507
+ p.install(fw);
508
+ p.start(fw);
509
+ return p;
510
+ }
511
+
512
+ it('无活跃计划时返回空字符串(不污染 system prompt)', () => {
513
+ const p = makePluginWithPrompts(tempDir);
514
+ expect(p._buildPlanStatusReminder()).toBe('');
515
+ });
516
+
517
+ it('当前 session 有 in_progress 计划时返回包含 planId 的摘要', async () => {
518
+ const p = makePluginWithPrompts(tempDir, 'sess_42');
519
+ await p._createPlan({
520
+ title: '天气修复', task: '',
521
+ sessionId: 'sess_42',
522
+ steps: [{ name: '读配置' }, { name: '编辑' }],
523
+ });
524
+ const reminder = p._buildPlanStatusReminder();
525
+ expect(reminder).toContain('当前活跃计划');
526
+ expect(reminder).toContain('天气修复');
527
+ expect(reminder).toContain('0/2');
528
+ expect(reminder).toContain('planId');
529
+ // 用 markdown bold 格式断言
530
+ expect(reminder).toContain('**当前步骤**: Step 1 — **读配置**');
531
+ });
532
+
533
+ it('执行一步后自动反映新进度', async () => {
534
+ const p = makePluginWithPrompts(tempDir, 'sess_42');
535
+ const r = await p._createPlan({
536
+ title: 't', task: '',
537
+ sessionId: 'sess_42',
538
+ steps: [{ name: '读' }, { name: '改' }],
539
+ });
540
+ await p._executeStep({
541
+ planId: r.planId, stepIndex: 0, status: 'completed',
542
+ tool: 'read', durationMs: 100,
543
+ });
544
+ const reminder = p._buildPlanStatusReminder();
545
+ // 调试用:打印完整 reminder
546
+ // console.log('REMINDER:\n' + reminder);
547
+ expect(reminder).toContain('1/2');
548
+ expect(reminder).toContain('✅ 1 完成');
549
+ expect(reminder).toContain('**上一步**: Step 1 ✅ 完成 (100ms)');
550
+ expect(reminder).toContain('**当前步骤**: Step 2 — **改**');
551
+ });
552
+
553
+ it('其他 session 的计划不显示(按 session 隔离)', async () => {
554
+ const p1 = makePluginWithPrompts(tempDir, 'sess_42');
555
+ const p2 = makePluginWithPrompts(tempDir, 'sess_99');
556
+ // 用 p1(session sess_42)创建计划
557
+ await p1._createPlan({
558
+ title: 'sess_42 的计划', task: '',
559
+ sessionId: 'sess_42',
560
+ steps: [{ name: 'x' }],
561
+ });
562
+ // 改用 p2(session sess_99)查 → 应该看不到
563
+ const reminder = p2._buildPlanStatusReminder();
564
+ expect(reminder).toBe('');
565
+ });
566
+
567
+ it('plan_create / execute_step / complete / fail / delete 都会 invalidate 缓存', async () => {
568
+ const p = makePluginWithPrompts(tempDir, 'sess_42');
569
+ const baseCalls = p._framework._invalidateCalls;
570
+
571
+ await p._createPlan({ title: 't', task: '', sessionId: 'sess_42', steps: [{ name: 'x' }] });
572
+ expect(p._framework._invalidateCalls).toBeGreaterThan(baseCalls);
573
+
574
+ // 取最新 planId 并继续操作
575
+ const plans = p._store.list({ status: 'in_progress' });
576
+ const planId = plans[0].planId;
577
+
578
+ await p._executeStep({ planId, stepIndex: 0, status: 'completed' });
579
+ await p._completePlan({ planId });
580
+ await p._failPlan({ planId, reason: 'test' }); // status 变 failed
581
+ await p._deletePlan({ planId });
582
+
583
+ // 每次操作都应触发至少一次 invalidate
584
+ expect(p._framework._invalidateCalls).toBeGreaterThanOrEqual(baseCalls + 5);
585
+ });
586
+
587
+ it('当 sessionId 为 null(CLI 全局调用)时不报错', () => {
588
+ const p = makePluginWithPrompts(tempDir, null);
589
+ expect(p._buildPlanStatusReminder()).toBe('');
590
+ });
591
+
592
+ it('上一步失败时显示错误信息(便于 LLM 决定重试)', async () => {
593
+ const p = makePluginWithPrompts(tempDir, 'sess_42');
594
+ const r = await p._createPlan({
595
+ title: 't', task: '',
596
+ sessionId: 'sess_42',
597
+ steps: [{ name: 'edit file' }, { name: 'next' }],
598
+ });
599
+ await p._executeStep({
600
+ planId: r.planId, stepIndex: 0, status: 'failed',
601
+ tool: 'edit', error: '权限被拒',
602
+ });
603
+ const reminder = p._buildPlanStatusReminder();
604
+ expect(reminder).toContain('❌ 1 失败');
605
+ expect(reminder).toContain('**上一步**: Step 1 ❌ 失败: 权限被拒');
606
+ // 失败时不会自动推进到下一步(让 LLM 决定重试/跳过/中止)
607
+ expect(reminder).toContain('**当前步骤**: 全部已执行');
608
+ });
609
+
610
+ it('多个 in_progress 计划时只显示前 3 个', async () => {
611
+ const p = makePluginWithPrompts(tempDir, 'sess_42');
612
+ for (let i = 0; i < 5; i++) {
613
+ await p._createPlan({
614
+ title: `计划 ${i}`, task: '',
615
+ sessionId: 'sess_42',
616
+ steps: [{ name: 's' }],
617
+ });
618
+ }
619
+ const reminder = p._buildPlanStatusReminder();
620
+ // 注:5 个计划在同一毫秒内创建,updatedAt 相同,
621
+ // 排序稳定性受 readdir 顺序影响(Windows 上不稳定)。
622
+ // 因此只断言"恰好显示 3 个"而不指定是哪 3 个。
623
+ const sectionCount = (reminder.match(/^### 计划/gm) || []).length;
624
+ expect(sectionCount).toBe(3);
625
+ // 至少 3 个 planId 出现
626
+ const planIdCount = (reminder.match(/\*\*planId\*\*/g) || []).length;
627
+ expect(planIdCount).toBe(3);
628
+ });
629
+ });
630
+ });