@code-fixer-23/pi-session-manager 1.0.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.
@@ -0,0 +1,1042 @@
1
+ import type {
2
+ ExtensionCommandContext,
3
+ ExtensionContext,
4
+ ExtensionUIContext,
5
+ SessionInfo,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { existsSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import {
10
+ handleSessionCleanInactive,
11
+ handleSessionCleanOlderThan,
12
+ handleSessionDeleteLast,
13
+ handleSessionSeries,
14
+ getSessionSeriesDataTempPath,
15
+ persistSessionSeriesData,
16
+ $TimestampCalculator,
17
+ type $SessionFilter,
18
+ type DurationRecord,
19
+ type RemoveSessionFiles,
20
+ SESION_TITLE_SEPARATOR,
21
+ type GetSessionEntryWithSeries,
22
+ sessionSeriesEntrySchema,
23
+ type SessionSeriesEntry,
24
+ type $SessionManagerConfigurator,
25
+ type SessionManagerConfig,
26
+ SessionConfigError,
27
+ } from ".";
28
+
29
+ type MockExtenstionCommandContext =
30
+ | Partial<ExtensionCommandContext>
31
+ | {
32
+ ui?: Partial<ExtensionUIContext>;
33
+ sessionManager?: Partial<ExtensionContext["sessionManager"]>;
34
+ };
35
+
36
+ class MockPastTimestampCalculator extends $TimestampCalculator {
37
+ #nowTimestamp = this.now;
38
+
39
+ hour(number = 1): number {
40
+ return this.#nowTimestamp - number * this.HOUR_IN_MS;
41
+ }
42
+
43
+ day(number = 1): number {
44
+ return this.#nowTimestamp - number * this.DAY_IN_MS;
45
+ }
46
+
47
+ week(number = 1): number {
48
+ return this.#nowTimestamp - number * this.WEEK_IN_MS;
49
+ }
50
+ }
51
+
52
+ class ModifiedTimeCalculator extends $TimestampCalculator {
53
+ hour = (multiplier: number): number => {
54
+ return multiplier * this.HOUR_IN_MS;
55
+ };
56
+
57
+ day = (multiplier: number): number => {
58
+ return multiplier * this.DAY_IN_MS;
59
+ };
60
+
61
+ week = (multiplier: number): number => {
62
+ return multiplier * this.WEEK_IN_MS;
63
+ };
64
+ }
65
+
66
+ class MockSessionFilter implements $SessionFilter {
67
+ #sessions: SessionInfo[] = [];
68
+
69
+ #timestampCalculator: $TimestampCalculator;
70
+
71
+ constructor(
72
+ sessions: SessionInfo[],
73
+ timestampCalculator: $TimestampCalculator,
74
+ ) {
75
+ this.#sessions = sessions;
76
+ this.#timestampCalculator = timestampCalculator;
77
+ }
78
+
79
+ get sessions(): SessionInfo[] {
80
+ return this.#sessions;
81
+ }
82
+
83
+ getSessionsThatHaveTheTitleAsAPrefix(title: string): Array<SessionInfo> {
84
+ return this.#sessions.filter((session) =>
85
+ session.name?.startsWith(`${title}${SESION_TITLE_SEPARATOR}`),
86
+ );
87
+ }
88
+
89
+ getSessionsThatAreTheLastNth(number: number) {
90
+ return this.#sessions.slice(-number);
91
+ }
92
+
93
+ getModifiedSessionsBasedOnDurationIntegerAndUnit(
94
+ integer: number,
95
+ durationUnit: DurationRecord["unit"],
96
+ ) {
97
+ return this.#sessions.filter((session) => {
98
+ switch (durationUnit) {
99
+ case "hours":
100
+ case "h":
101
+ return (
102
+ session.modified.getTime() < this.#timestampCalculator.hour(integer)
103
+ );
104
+ case "days":
105
+ case "d":
106
+ return (
107
+ session.modified.getTime() < this.#timestampCalculator.day(integer)
108
+ );
109
+ case "weeks":
110
+ case "w":
111
+ return (
112
+ session.modified.getTime() < this.#timestampCalculator.week(integer)
113
+ );
114
+ default: {
115
+ const exhausted: never = durationUnit;
116
+
117
+ return exhausted;
118
+ }
119
+ }
120
+ });
121
+ }
122
+
123
+ getModifiedSessionsBasedOnDayLimit() {
124
+ return this.#sessions.filter(
125
+ (session) =>
126
+ session.modified.getTime() < this.#timestampCalculator.day(3),
127
+ );
128
+ }
129
+ }
130
+
131
+ const generateSessionsBasedOnModifiedTimeCalculation = (
132
+ modifiedTimeOffsetCalculator: (multiplier: number) => number,
133
+ ): SessionInfo[] => {
134
+ const count = Math.max(6, Math.floor(Math.random() * 12));
135
+ const now = Date.now();
136
+ return Array.from({ length: count }, (_, i) => ({
137
+ path: `/path/to/session/${i}`,
138
+ id: `session-id-${i}`,
139
+ cwd: `/user/work/${i}`,
140
+ name: `Session ${i}`,
141
+ parentSessionPath: i > 5 ? `/path/to/parent/${i}` : "/path/to/parent",
142
+ created: new Date(now - 1000000 * i),
143
+ modified: new Date(now - modifiedTimeOffsetCalculator(i)),
144
+ messageCount: i * 2,
145
+ firstMessage: `Hello from session ${i}`,
146
+ allMessagesText: `Full history for session ${i}`,
147
+ }));
148
+ };
149
+
150
+ class SessionManagerConfiguratorMock implements $SessionManagerConfigurator {
151
+ #config: SessionManagerConfig;
152
+
153
+ get config(): SessionManagerConfig {
154
+ return this.#config;
155
+ }
156
+
157
+ constructor(config: Partial<SessionManagerConfig> = {}) {
158
+ this.#config = {
159
+ sessionDeletionDayLimit: 3,
160
+ seriesRecord: {},
161
+ ...config,
162
+ };
163
+ }
164
+
165
+ appendSessionSeriesBasedOnCwd(
166
+ cwd: string,
167
+ series: string,
168
+ title: string,
169
+ ): void {
170
+ const normalizedSeries = series.trim();
171
+ const normalizedTitle = title.trim();
172
+ const cwdSeriesRecord = this.#config.seriesRecord[cwd] ?? {};
173
+ const titles = cwdSeriesRecord[normalizedSeries] ?? [];
174
+
175
+ if (
176
+ !titles.some((existingTitle) => existingTitle.trim() === normalizedTitle)
177
+ ) {
178
+ cwdSeriesRecord[normalizedSeries] = titles.concat(normalizedTitle);
179
+ }
180
+
181
+ this.#config.seriesRecord[cwd] = cwdSeriesRecord;
182
+ }
183
+
184
+ deleteSessionSeriesBasedOnCwd(cwd: string, series: string): void {
185
+ const cwdSeriesRecord = this.#config.seriesRecord[cwd];
186
+
187
+ if (!cwdSeriesRecord) {
188
+ return;
189
+ }
190
+
191
+ delete cwdSeriesRecord[series.trim()];
192
+ }
193
+
194
+ configureSessionDeletionDayLimit(days: number): void {
195
+ this.#config.sessionDeletionDayLimit = days;
196
+ }
197
+
198
+ getSessionDeletionDayLimit(): number | SessionConfigError {
199
+ if (this.#config.sessionDeletionDayLimit < 0) {
200
+ return new SessionConfigError(
201
+ "sessionDeletionDayLimit must be a non-negative number",
202
+ );
203
+ }
204
+
205
+ return this.#config.sessionDeletionDayLimit;
206
+ }
207
+
208
+ generateInitialConfig(cwd: string): void {
209
+ this.#config.seriesRecord[cwd] = {};
210
+ }
211
+
212
+ getSessionSeriesForCwd(cwd: string): string[] | SessionConfigError {
213
+ return Object.keys(this.#config.seriesRecord[cwd] ?? {});
214
+ }
215
+
216
+ getSessionTitlesForSeriesBasedOnCwd(
217
+ cwd: string,
218
+ series: string,
219
+ ): string[] | SessionConfigError {
220
+ const cwdSeriesRecord = this.#config.seriesRecord[cwd] ?? {};
221
+
222
+ return cwdSeriesRecord[series.trim()] ?? [];
223
+ }
224
+ }
225
+
226
+ // THis is written like this so that I can use the outline to find tests
227
+ const test = it
228
+ .extend("sessions", () => {
229
+ const modifiedTimeCalculator = new ModifiedTimeCalculator();
230
+ return [
231
+ modifiedTimeCalculator.hour,
232
+ modifiedTimeCalculator.day,
233
+ modifiedTimeCalculator.week,
234
+ ]
235
+ .map(generateSessionsBasedOnModifiedTimeCalculation)
236
+ .flat();
237
+ })
238
+ .extend("timestampCalculator", new MockPastTimestampCalculator());
239
+
240
+ function castToExtensionContext(context: MockExtenstionCommandContext) {
241
+ return context as ExtensionCommandContext;
242
+ }
243
+
244
+ const tempSessionDataPath = getSessionSeriesDataTempPath();
245
+
246
+ const mockRemoveSessionFiles = vi.fn<RemoveSessionFiles>();
247
+
248
+ describe("persisted session series data", () => {
249
+ it("stores data in the OS temp dir and consumes it on session start", () => {
250
+ const sessionData = {
251
+ sessionName: `Implement Auth${SESION_TITLE_SEPARATOR}Create JWT Token`,
252
+ entry: {
253
+ customType: sessionSeriesEntrySchema.entries.customType.literal,
254
+ series: "Implement Auth",
255
+ createdAt: new Date().toISOString(),
256
+ },
257
+ };
258
+
259
+ persistSessionSeriesData(sessionData);
260
+
261
+ expect(tempSessionDataPath.startsWith(tmpdir())).toBe(true);
262
+ expect(existsSync(tempSessionDataPath)).toBe(true);
263
+
264
+ const pi = {
265
+ setSessionName: vi.fn(),
266
+ appendEntry: vi.fn(),
267
+ };
268
+ const ctx = {
269
+ ui: {
270
+ notify: vi.fn(),
271
+ },
272
+ };
273
+
274
+ expect(pi.setSessionName).toHaveBeenCalledWith(sessionData.sessionName);
275
+ expect(pi.appendEntry).toHaveBeenCalledWith(sessionData.entry.customType, {
276
+ series: sessionData.entry.series,
277
+ createdAt: sessionData.entry.createdAt,
278
+ });
279
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
280
+ "Setting necessary session data",
281
+ );
282
+ expect(existsSync(tempSessionDataPath)).toBe(false);
283
+ });
284
+ });
285
+
286
+ describe("handleSessionCleanInactive", () => {
287
+ test("gets rid of all sessions that haven't been modified in the last three days", ({
288
+ sessions,
289
+ timestampCalculator,
290
+ }) => {
291
+ const context = {
292
+ ui: {
293
+ notify: vi.fn<ExtensionContext["ui"]["notify"]>(),
294
+ },
295
+ } satisfies MockExtenstionCommandContext;
296
+
297
+ const mockSessionFilter = new MockSessionFilter(
298
+ sessions,
299
+ timestampCalculator,
300
+ );
301
+
302
+ const getModifiedSessionsBasedOnDayLimit = vi.spyOn(
303
+ mockSessionFilter,
304
+ "getModifiedSessionsBasedOnDayLimit",
305
+ );
306
+
307
+ const timeStampDaySpy = vi.spyOn(timestampCalculator, "day");
308
+
309
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock();
310
+
311
+ handleSessionCleanInactive(
312
+ {
313
+ sessionFilter: mockSessionFilter,
314
+ sessionManagerConfigurator,
315
+ removeSessionFiles: mockRemoveSessionFiles,
316
+ },
317
+ castToExtensionContext(context),
318
+ );
319
+
320
+ expect(context.ui.notify).toHaveBeenCalledWith(
321
+ "Getting rid of all sessions that have been inactive for three days",
322
+ "warning",
323
+ );
324
+
325
+ expect(getModifiedSessionsBasedOnDayLimit).toHaveBeenCalled();
326
+
327
+ expect(timeStampDaySpy).toHaveBeenCalledWith(3);
328
+
329
+ expect(mockRemoveSessionFiles).toHaveBeenCalledWith(
330
+ getModifiedSessionsBasedOnDayLimit.mock.results[0]?.value,
331
+ );
332
+ });
333
+ });
334
+
335
+ describe("handleSessionCleanOlderThan", () => {
336
+ test("cleans sessions older than the specified unit", ({
337
+ sessions,
338
+ timestampCalculator,
339
+ }) => {
340
+ const context = {
341
+ ui: {
342
+ notify: vi.fn(),
343
+ },
344
+ } satisfies MockExtenstionCommandContext;
345
+
346
+ const mockSessionFilter = new MockSessionFilter(
347
+ sessions,
348
+ timestampCalculator,
349
+ );
350
+
351
+ const getModifiedSessionsBasedOnDurationIntegerAndUnit = vi.spyOn(
352
+ mockSessionFilter,
353
+ "getModifiedSessionsBasedOnDurationIntegerAndUnit",
354
+ );
355
+ const timeStampDaySpy = vi.spyOn(timestampCalculator, "day");
356
+
357
+ const durationRecord = { integer: 7, unit: "days" } as const;
358
+ handleSessionCleanOlderThan(
359
+ durationRecord,
360
+ {
361
+ sessionFilter: mockSessionFilter,
362
+ removeSessionFiles: mockRemoveSessionFiles,
363
+ },
364
+ castToExtensionContext(context),
365
+ );
366
+
367
+ expect(context.ui.notify).toHaveBeenCalledWith(
368
+ `Deleteing sessions that are from ${durationRecord.integer} ${durationRecord.unit} ago`,
369
+ );
370
+
371
+ expect(
372
+ getModifiedSessionsBasedOnDurationIntegerAndUnit,
373
+ ).toHaveBeenCalledWith(durationRecord.integer, durationRecord.unit);
374
+
375
+ expect(timeStampDaySpy).toHaveBeenCalledWith(durationRecord.integer);
376
+
377
+ expect(mockRemoveSessionFiles).toHaveBeenCalledWith(
378
+ getModifiedSessionsBasedOnDurationIntegerAndUnit.mock.results[0]?.value,
379
+ );
380
+ });
381
+ });
382
+
383
+ describe("handleSessionDeleteLast", () => {
384
+ test("deletes the last sessions by a specified nth", ({
385
+ sessions,
386
+ timestampCalculator,
387
+ }) => {
388
+ const context = {
389
+ ui: {
390
+ notify: vi.fn(),
391
+ },
392
+ } satisfies MockExtenstionCommandContext;
393
+
394
+ const mockSessionFilter = new MockSessionFilter(
395
+ sessions,
396
+ timestampCalculator,
397
+ );
398
+
399
+ const getSessionsThatAreTheLastNth = vi.spyOn(
400
+ mockSessionFilter,
401
+ "getSessionsThatAreTheLastNth",
402
+ );
403
+
404
+ const nthSessions = 5;
405
+ handleSessionDeleteLast(
406
+ nthSessions,
407
+ {
408
+ sessionFilter: mockSessionFilter,
409
+ removeSessionFiles: mockRemoveSessionFiles,
410
+ },
411
+ castToExtensionContext(context),
412
+ );
413
+
414
+ expect(context.ui.notify).toHaveBeenCalledWith(
415
+ `Deleting the last ${nthSessions}`,
416
+ );
417
+
418
+ expect(getSessionsThatAreTheLastNth).toHaveBeenCalledWith(nthSessions);
419
+
420
+ expect(mockRemoveSessionFiles).toHaveBeenCalledWith(
421
+ getSessionsThatAreTheLastNth.mock.results[0]?.value,
422
+ );
423
+ });
424
+ });
425
+
426
+ describe("handleSessionSeries", () => {
427
+ describe("how it handles creation of tasks", () => {
428
+ it("creates a session series when create is passed", async () => {
429
+ const sessionCtx = {
430
+ cwd: "/session/create",
431
+ ui: {
432
+ notify: vi.fn(),
433
+ },
434
+ };
435
+
436
+ const context = {
437
+ cwd: "/pi-packages",
438
+ newSession: vi.fn<ExtensionCommandContext["newSession"]>(
439
+ async (options) => {
440
+ options?.withSession?.(sessionCtx as never);
441
+ return { cancelled: false };
442
+ },
443
+ ),
444
+ ui: {
445
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
446
+ input: vi
447
+ .fn<ExtensionUIContext["input"]>()
448
+ .mockResolvedValue("Implement Auth")
449
+ .mockResolvedValue("Create JWT Token"),
450
+ },
451
+ } satisfies MockExtenstionCommandContext;
452
+
453
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock();
454
+ const appendSessionSeriesBasedOnCwdSpy = vi.spyOn(
455
+ sessionManagerConfigurator,
456
+ "appendSessionSeriesBasedOnCwd",
457
+ );
458
+
459
+ await handleSessionSeries(
460
+ "create",
461
+ {
462
+ sessionManagerConfigurator,
463
+ sessionFilter: new MockSessionFilter(
464
+ [],
465
+ new MockPastTimestampCalculator(),
466
+ ),
467
+ getSessionEntryWithSeries() {
468
+ return undefined;
469
+ },
470
+ removeSessionFiles() {
471
+ return;
472
+ },
473
+ },
474
+ castToExtensionContext(context),
475
+ );
476
+
477
+ expect(context.ui.input).toHaveBeenCalledWith(
478
+ "What is the name of your session series?",
479
+ "What are you focused on?",
480
+ );
481
+
482
+ expect(context.ui.input).toHaveBeenCalledWith(
483
+ "What is the name of the new session you want to make in this one?",
484
+ "What task is a part of what you are focusing on?",
485
+ );
486
+
487
+ const series = context.ui.input.mock.settledResults[0]?.value;
488
+ const title = context.ui.input.mock.settledResults[1]?.value;
489
+
490
+ expect(context.newSession).toHaveBeenCalledWith({
491
+ withSession: expect.any(Function),
492
+ });
493
+
494
+ expect(appendSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
495
+ sessionCtx.cwd,
496
+ series,
497
+ title,
498
+ );
499
+
500
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
501
+ "Your session series has been created",
502
+ );
503
+ });
504
+
505
+ const seriesInput = "Implement Auth";
506
+ it("keeps asking for a unique trimmed session series when creating", async () => {
507
+ const sessionCtx = {
508
+ cwd: "/session/create-unique",
509
+ ui: {
510
+ notify: vi.fn(),
511
+ },
512
+ };
513
+
514
+ const context = {
515
+ cwd: "/pi-packages",
516
+ newSession: vi.fn<ExtensionCommandContext["newSession"]>(
517
+ async (options) => {
518
+ options?.withSession?.(sessionCtx as never);
519
+ return { cancelled: false };
520
+ },
521
+ ),
522
+ ui: {
523
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
524
+ input: vi
525
+ .fn<ExtensionUIContext["input"]>()
526
+ .mockResolvedValueOnce(" Implement Auth ")
527
+ .mockResolvedValueOnce(" Implement Billing ")
528
+ .mockResolvedValueOnce(" Create JWT Token "),
529
+ },
530
+ } satisfies MockExtenstionCommandContext;
531
+
532
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock({
533
+ seriesRecord: {
534
+ [context.cwd]: {
535
+ [seriesInput]: ["Existing title"],
536
+ },
537
+ },
538
+ });
539
+
540
+ const getSessionSeriesForCwdSpy = vi.spyOn(
541
+ sessionManagerConfigurator,
542
+ "getSessionSeriesForCwd",
543
+ );
544
+ const appendSessionSeriesBasedOnCwdSpy = vi.spyOn(
545
+ sessionManagerConfigurator,
546
+ "appendSessionSeriesBasedOnCwd",
547
+ );
548
+
549
+ await handleSessionSeries(
550
+ "create",
551
+ {
552
+ sessionManagerConfigurator,
553
+ sessionFilter: new MockSessionFilter(
554
+ [],
555
+ new MockPastTimestampCalculator(),
556
+ ),
557
+ getSessionEntryWithSeries() {
558
+ return undefined;
559
+ },
560
+ removeSessionFiles() {
561
+ return;
562
+ },
563
+ },
564
+ castToExtensionContext(context),
565
+ );
566
+
567
+ expect(context.ui.input).toHaveBeenCalledWith(
568
+ "What is the name of your session series?",
569
+ "What are you focused on?",
570
+ );
571
+
572
+ expect(getSessionSeriesForCwdSpy).toHaveBeenCalledWith(context.cwd);
573
+
574
+ expect(context.ui.notify).toHaveBeenCalledWith(
575
+ `This series has already been added ${seriesInput}`,
576
+ "warning",
577
+ );
578
+
579
+ expect(context.ui.input).toHaveBeenCalledTimes(3);
580
+
581
+ expect(appendSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
582
+ sessionCtx.cwd,
583
+ "Implement Billing",
584
+ "Create JWT Token",
585
+ );
586
+
587
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
588
+ "Your session series has been created",
589
+ );
590
+ });
591
+ });
592
+
593
+ it("deletes a session series when delete is passed", async () => {
594
+ const sessionSerieses = [
595
+ "refactor-auth-middleware",
596
+ "fix-memory-leak-prod",
597
+ "implement-graphql-subscriptions",
598
+ "update-dependency-vulnerabilities",
599
+ "ui-component-library-migration",
600
+ "optimize-database-queries",
601
+ "setup-ci-cd-pipeline",
602
+ "unit-test-coverage-boost",
603
+ "api-documentation-swagger",
604
+ "feature-flag-cleanup",
605
+ ];
606
+
607
+ const now = Date.now();
608
+
609
+ const generateSessionsFromSerieses = (): SessionInfo[] => {
610
+ return sessionSerieses
611
+ .map((series, i) =>
612
+ Array.from({ length: 3 }, (_, j) => ({
613
+ path: `/path/to/session/${i}__${j}`,
614
+ id: `session-id-${i}__${j}`,
615
+ cwd: `/user/work/${i}__${j}`,
616
+ name: `${series}${SESION_TITLE_SEPARATOR}Session ${i}__${j}`,
617
+ parentSessionPath:
618
+ j > 5 ? `/path/to/parent/${i}__${j}` : `/path/to/parent/${i}`,
619
+ created: new Date(now - 1000000 * i),
620
+ modified: new Date(now - i * 1000),
621
+ messageCount: i * j * 2,
622
+ firstMessage: `Hello from session ${i}`,
623
+ allMessagesText: `Full history for session ${i}`,
624
+ })),
625
+ )
626
+ .flat();
627
+ };
628
+
629
+ const randomSeries =
630
+ sessionSerieses[Math.floor(Math.random() * sessionSerieses.length)] ??
631
+ sessionSerieses[0];
632
+
633
+ const context = {
634
+ cwd: "/user/work/0",
635
+ ui: {
636
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
637
+ select: vi
638
+ .fn<ExtensionUIContext["select"]>()
639
+ .mockResolvedValue(randomSeries),
640
+ },
641
+ } satisfies MockExtenstionCommandContext;
642
+
643
+ const mockSessionFilter = new MockSessionFilter(
644
+ generateSessionsFromSerieses(),
645
+ new MockPastTimestampCalculator(),
646
+ );
647
+
648
+ const mockGetSessionsThatHaveTheTitleAsAPrefixSpy = vi.spyOn(
649
+ mockSessionFilter,
650
+ "getSessionsThatHaveTheTitleAsAPrefix",
651
+ );
652
+
653
+ const removeSessionFiles = vi.fn<RemoveSessionFiles>();
654
+
655
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock();
656
+ sessionManagerConfigurator.generateInitialConfig("/user/work/0");
657
+ for (const [index, series] of sessionSerieses.entries()) {
658
+ sessionManagerConfigurator.appendSessionSeriesBasedOnCwd(
659
+ "/user/work/0",
660
+ series,
661
+ `Session ${index}`,
662
+ );
663
+ }
664
+ const getSessionSeriesForCwdSpy = vi.spyOn(
665
+ sessionManagerConfigurator,
666
+ "getSessionSeriesForCwd",
667
+ );
668
+ const deleteSessionSeriesBasedOnCwdSpy = vi.spyOn(
669
+ sessionManagerConfigurator,
670
+ "deleteSessionSeriesBasedOnCwd",
671
+ );
672
+
673
+ await handleSessionSeries(
674
+ "delete",
675
+ {
676
+ sessionManagerConfigurator,
677
+ sessionFilter: mockSessionFilter,
678
+
679
+ getSessionEntryWithSeries() {
680
+ return undefined;
681
+ },
682
+ removeSessionFiles,
683
+ },
684
+ castToExtensionContext(context),
685
+ );
686
+
687
+ expect(getSessionSeriesForCwdSpy).toHaveBeenCalledWith(context.cwd);
688
+
689
+ expect(context.ui.select).toHaveBeenCalledWith(
690
+ "Which session series would you like to delete?",
691
+ sessionSerieses,
692
+ );
693
+
694
+ expect(mockGetSessionsThatHaveTheTitleAsAPrefixSpy).toHaveBeenCalledWith(
695
+ context.ui.select.mock.settledResults[0]?.value,
696
+ );
697
+
698
+ expect(removeSessionFiles).toHaveBeenCalledWith(
699
+ mockGetSessionsThatHaveTheTitleAsAPrefixSpy.mock.results[0]?.value,
700
+ );
701
+ expect(deleteSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
702
+ context.cwd,
703
+ context.ui.select.mock.settledResults[0]?.value,
704
+ );
705
+
706
+ expect(context.ui.notify).toHaveBeenCalledWith(
707
+ `This series ${context.ui.select.mock.settledResults[0]?.value} and it's related sessions`,
708
+ );
709
+ });
710
+
711
+ it("Makes a new session in a series new is passed", async () => {
712
+ const sessionSeries = [
713
+ "refactor-auth-middleware",
714
+ "fix-memory-leak-prod",
715
+ "implement-graphql-subscriptions",
716
+ "update-dependency-vulnerabilities",
717
+ "ui-component-library-migration",
718
+ "optimize-database-queries",
719
+ "setup-ci-cd-pipeline",
720
+ "unit-test-coverage-boost",
721
+ "api-documentation-swagger",
722
+ "feature-flag-cleanup",
723
+ ];
724
+
725
+ const randomSeries =
726
+ sessionSeries[Math.floor(Math.random() * sessionSeries.length)] ??
727
+ sessionSeries[0];
728
+
729
+ const sessionCtx = {
730
+ cwd: "/session/new",
731
+ ui: {
732
+ notify: vi.fn(),
733
+ },
734
+ };
735
+
736
+ const context = {
737
+ cwd: "/user/work/0",
738
+ newSession: vi.fn<ExtensionCommandContext["newSession"]>(
739
+ async (options) => {
740
+ options?.withSession?.(sessionCtx as never);
741
+ return { cancelled: false };
742
+ },
743
+ ),
744
+ ui: {
745
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
746
+ input: vi
747
+ .fn<ExtensionUIContext["input"]>()
748
+ .mockResolvedValue("Add tests to the lib/index.ts file"),
749
+ select: vi
750
+ .fn<ExtensionUIContext["select"]>()
751
+ .mockResolvedValue(randomSeries),
752
+ },
753
+ } satisfies MockExtenstionCommandContext;
754
+
755
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock();
756
+ sessionManagerConfigurator.generateInitialConfig("/user/work/0");
757
+ for (const [index, series] of sessionSeries.entries()) {
758
+ sessionManagerConfigurator.appendSessionSeriesBasedOnCwd(
759
+ "/user/work/0",
760
+ series,
761
+ `Session ${index}`,
762
+ );
763
+ }
764
+ const getSessionSeriesForCwdSpy = vi.spyOn(
765
+ sessionManagerConfigurator,
766
+ "getSessionSeriesForCwd",
767
+ );
768
+ const appendSessionSeriesBasedOnCwdSpy = vi.spyOn(
769
+ sessionManagerConfigurator,
770
+ "appendSessionSeriesBasedOnCwd",
771
+ );
772
+
773
+ const sessionData = await handleSessionSeries(
774
+ "new",
775
+ {
776
+ sessionManagerConfigurator,
777
+ sessionFilter: new MockSessionFilter(
778
+ [],
779
+ new MockPastTimestampCalculator(),
780
+ ),
781
+
782
+ getSessionEntryWithSeries() {
783
+ return undefined;
784
+ },
785
+ removeSessionFiles() {
786
+ return;
787
+ },
788
+ },
789
+ castToExtensionContext(context),
790
+ );
791
+
792
+ expect(getSessionSeriesForCwdSpy).toHaveBeenCalledWith(context.cwd);
793
+
794
+ expect(context.ui.select).toHaveBeenCalledWith(
795
+ "Which session series would you like to create a new session in?",
796
+ sessionSeries,
797
+ );
798
+
799
+ expect(context.ui.input).toHaveBeenCalledWith(
800
+ "What is the name of the this new session?",
801
+ "What do you want your agent to do now?",
802
+ );
803
+
804
+ expect(appendSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
805
+ sessionCtx.cwd,
806
+ randomSeries,
807
+ "Add tests to the lib/index.ts file",
808
+ );
809
+
810
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
811
+ expect.stringContaining(
812
+ `You have created a new session in ${context.ui.select.mock.settledResults[0]?.value}`,
813
+ ),
814
+ );
815
+
816
+ expect(context.newSession).toHaveBeenCalledWith({
817
+ withSession: expect.any(Function),
818
+ });
819
+
820
+ const sessionSeriesAndTitle = `${context.ui.select.mock.settledResults[0]?.value}${SESION_TITLE_SEPARATOR}${context.ui.input.mock.settledResults[0]?.value}`;
821
+ expect(sessionData).toMatchObject({ sessionName: sessionSeriesAndTitle });
822
+
823
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
824
+ expect.stringContaining(
825
+ `You have created a new session in ${context.ui.select.mock.settledResults[0]?.value}`,
826
+ ),
827
+ );
828
+ });
829
+
830
+ it("keeps asking for a unique trimmed title when creating a new session in a series", async () => {
831
+ const selectedSeries = "refactor-auth-middleware";
832
+
833
+ const sessionCtx = {
834
+ cwd: "/session/new-series",
835
+ ui: {
836
+ notify: vi.fn(),
837
+ },
838
+ };
839
+
840
+ const context = {
841
+ cwd: "/user/work/0",
842
+ newSession: vi.fn<ExtensionCommandContext["newSession"]>(
843
+ async (options) => {
844
+ options?.withSession?.(sessionCtx as never);
845
+ return { cancelled: false };
846
+ },
847
+ ),
848
+ ui: {
849
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
850
+ input: vi
851
+ .fn<ExtensionUIContext["input"]>()
852
+ .mockResolvedValueOnce(" Design JWT ")
853
+ .mockResolvedValueOnce(" Refactor Hooks "),
854
+ select: vi
855
+ .fn<ExtensionUIContext["select"]>()
856
+ .mockResolvedValue(selectedSeries),
857
+ },
858
+ } satisfies MockExtenstionCommandContext;
859
+
860
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock({
861
+ seriesRecord: {
862
+ [context.cwd]: {
863
+ [selectedSeries]: ["Design JWT"],
864
+ },
865
+ },
866
+ });
867
+
868
+ const getSessionTitlesForSeriesBasedOnCwdSpy = vi.spyOn(
869
+ sessionManagerConfigurator,
870
+ "getSessionTitlesForSeriesBasedOnCwd",
871
+ );
872
+ const appendSessionSeriesBasedOnCwdSpy = vi.spyOn(
873
+ sessionManagerConfigurator,
874
+ "appendSessionSeriesBasedOnCwd",
875
+ );
876
+
877
+ await handleSessionSeries(
878
+ "new",
879
+ {
880
+ sessionManagerConfigurator,
881
+ sessionFilter: new MockSessionFilter(
882
+ [],
883
+ new MockPastTimestampCalculator(),
884
+ ),
885
+
886
+ getSessionEntryWithSeries() {
887
+ return undefined;
888
+ },
889
+ removeSessionFiles() {
890
+ return;
891
+ },
892
+ },
893
+ castToExtensionContext(context),
894
+ );
895
+
896
+ expect(getSessionTitlesForSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
897
+ context.cwd,
898
+ selectedSeries,
899
+ );
900
+ expect(context.ui.notify).toHaveBeenCalledWith(
901
+ `This title has already been added Design JWT`,
902
+ "warning",
903
+ );
904
+ expect(context.ui.input).toHaveBeenCalledTimes(2);
905
+ expect(appendSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
906
+ sessionCtx.cwd,
907
+ selectedSeries,
908
+ "Refactor Hooks",
909
+ );
910
+
911
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
912
+ `You have created a new session in ${selectedSeries}
913
+ with Refactor Hooks
914
+ `,
915
+ );
916
+ });
917
+
918
+ it("continues a session in a series when continue is passed", async () => {
919
+ const sessionCtx = {
920
+ cwd: "/session/continue",
921
+ ui: {
922
+ notify: vi.fn(),
923
+ },
924
+ };
925
+
926
+ const context = {
927
+ cwd: "/user/work/0",
928
+ sessionManager: {
929
+ getEntries:
930
+ vi.fn<ExtensionCommandContext["sessionManager"]["getEntries"]>(),
931
+ },
932
+ newSession: vi.fn<ExtensionCommandContext["newSession"]>(
933
+ async (options) => {
934
+ options?.withSession?.(sessionCtx as never);
935
+ return { cancelled: false };
936
+ },
937
+ ),
938
+ ui: {
939
+ notify: vi.fn<ExtensionUIContext["notify"]>(),
940
+ input: vi
941
+ .fn<ExtensionUIContext["input"]>()
942
+ .mockResolvedValueOnce(" Make coverage for Processor full ")
943
+ .mockResolvedValueOnce(" Make coverage for Processor optimized "),
944
+ },
945
+ } satisfies MockExtenstionCommandContext;
946
+
947
+ const getSessionEntryWithSeries = vi.fn<GetSessionEntryWithSeries>();
948
+ const seriesEntry = {
949
+ type: "custom",
950
+ customType: sessionSeriesEntrySchema.entries.customType.literal,
951
+ data: {
952
+ series: "refactor-auth-middleware",
953
+ createdAt: new Date().toISOString(),
954
+ },
955
+ } as SessionSeriesEntry;
956
+
957
+ getSessionEntryWithSeries.mockReturnValue(seriesEntry);
958
+
959
+ const sessionManagerConfigurator = new SessionManagerConfiguratorMock({
960
+ seriesRecord: {
961
+ [context.cwd]: {
962
+ [seriesEntry.data.series]: ["Make coverage for Processor full"],
963
+ },
964
+ },
965
+ });
966
+
967
+ const appendSessionSeriesBasedOnCwdSpy = vi.spyOn(
968
+ sessionManagerConfigurator,
969
+ "appendSessionSeriesBasedOnCwd",
970
+ );
971
+
972
+ const sessionData = await handleSessionSeries(
973
+ "continue",
974
+ {
975
+ sessionFilter: new MockSessionFilter(
976
+ [
977
+ {
978
+ path: "/path/to/session/continue-0",
979
+ id: "session-id-continue-0",
980
+ cwd: context.cwd,
981
+ name: `${seriesEntry.data.series}${SESION_TITLE_SEPARATOR}Make coverage for Processor full`,
982
+ parentSessionPath: "/path/to/parent/continue-0",
983
+ created: new Date(),
984
+ modified: new Date(),
985
+ messageCount: 1,
986
+ firstMessage: "Hello from session continue",
987
+ allMessagesText: "Full history for session continue",
988
+ },
989
+ ],
990
+ new MockPastTimestampCalculator(),
991
+ ),
992
+ getSessionEntryWithSeries,
993
+ sessionManagerConfigurator,
994
+
995
+ removeSessionFiles() {
996
+ return;
997
+ },
998
+ },
999
+ castToExtensionContext(context),
1000
+ );
1001
+
1002
+ expect(context.sessionManager.getEntries).toHaveBeenCalled();
1003
+
1004
+ expect(getSessionEntryWithSeries).toHaveBeenCalledWith(
1005
+ context.sessionManager.getEntries.mock.results[0]?.value,
1006
+ );
1007
+
1008
+ const entry = getSessionEntryWithSeries.mock.results[0]
1009
+ ?.value as SessionSeriesEntry;
1010
+
1011
+ expect(entry).toEqual(expect.schemaMatching(sessionSeriesEntrySchema));
1012
+
1013
+ expect(context.ui.input).toHaveBeenCalledWith(
1014
+ `What's the new title for the session in series ${entry.data.series}`,
1015
+ undefined,
1016
+ );
1017
+
1018
+ expect(context.ui.notify).toHaveBeenCalledWith(
1019
+ `This title has already been added Make coverage for Processor full`,
1020
+ "warning",
1021
+ );
1022
+ expect(context.ui.input).toHaveBeenCalledTimes(2);
1023
+
1024
+ expect(context.newSession).toHaveBeenCalledWith({
1025
+ withSession: expect.any(Function),
1026
+ });
1027
+
1028
+ const sessionSeriesAndTitle = `${entry.data.series}${SESION_TITLE_SEPARATOR}${context.ui.input.mock.settledResults[1]?.value?.trim()}`;
1029
+ expect(sessionData).toMatchObject({ sessionName: sessionSeriesAndTitle });
1030
+ expect(appendSessionSeriesBasedOnCwdSpy).toHaveBeenCalledWith(
1031
+ sessionCtx.cwd,
1032
+ entry.data.series,
1033
+ "Make coverage for Processor optimized",
1034
+ );
1035
+
1036
+ expect(sessionCtx.ui.notify).toHaveBeenCalledWith(
1037
+ `You have created a new session in ${entry.data.series}
1038
+ with ${context.ui.input.mock.settledResults[1]?.value?.trim()}
1039
+ `,
1040
+ );
1041
+ });
1042
+ });