@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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.
@@ -1,829 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
3
- import { writeFile } from "fs/promises"
4
- import { CustomCompactionPlugin } from "../custom-compaction"
5
- import {
6
- createMockCtx,
7
- createTempDir,
8
- cleanupTempDir,
9
- FIXTURE_SESSION_STATE,
10
- FIXTURE_STORY_MD,
11
- FIXTURE_EPIC_STATE,
12
- FIXTURE_TODOS,
13
- } from "./helpers/mock-ctx"
14
-
15
- // =============================================================================
16
- // LOCAL REPLICAS of internal functions (not exported from plugin)
17
- // These mirror the source logic so we can unit-test the algorithms directly.
18
- // =============================================================================
19
-
20
- const SERVICE_AGENTS = ["title", "compaction", "summary", "system"]
21
-
22
- function isRealAgent(agent: string | null): boolean {
23
- if (!agent) return false
24
- return !SERVICE_AGENTS.includes(agent.toLowerCase())
25
- }
26
-
27
- const BASE_FILES = ["CLAUDE.md", "AGENTS.md"]
28
-
29
- const AGENT_FILES: Record<string, string[]> = {
30
- dev: [...BASE_FILES, "docs/coding-standards/README.md", "docs/coding-standards/patterns.md"],
31
- coder: [...BASE_FILES, "docs/coding-standards/patterns.md"],
32
- architect: [...BASE_FILES, "docs/architecture.md", "docs/prd.md", "docs/coding-standards/README.md", "docs/architecture/adr"],
33
- pm: [...BASE_FILES, "docs/prd.md", "docs/architecture.md", "docs/sprint-artifacts/sprint-status.yaml", "docs/sprint-artifacts/backlog"],
34
- analyst: [...BASE_FILES, "docs/requirements/requirements.md", "docs/prd.md"],
35
- researcher: [...BASE_FILES, "docs/prd.md", "docs/research"],
36
- crawler: [...BASE_FILES],
37
- "change-manager": [...BASE_FILES, "docs/prd.md", "docs/architecture.md"],
38
- }
39
-
40
- const DEFAULT_FILES = [...BASE_FILES, "docs/prd.md", "docs/architecture.md"]
41
-
42
- const MUST_READ_FILES: Record<string, string[]> = {
43
- dev: ["AGENTS.md", "CLAUDE.md", "docs/coding-standards/README.md"],
44
- coder: ["AGENTS.md", "CLAUDE.md", "docs/coding-standards/README.md"],
45
- architect: ["AGENTS.md", "CLAUDE.md", "docs/prd.md", "docs/architecture.md"],
46
- pm: ["AGENTS.md", "CLAUDE.md", "docs/prd.md"],
47
- analyst: ["AGENTS.md", "CLAUDE.md", "docs/prd.md"],
48
- researcher: ["AGENTS.md", "CLAUDE.md"],
49
- default: ["AGENTS.md", "CLAUDE.md"],
50
- }
51
-
52
- // =============================================================================
53
- // UNIT TESTS: isRealAgent (replica)
54
- // =============================================================================
55
- describe("isRealAgent", () => {
56
- it("returns false for null", () => {
57
- expect(isRealAgent(null)).toBe(false)
58
- })
59
-
60
- it("returns false for service agents", () => {
61
- for (const agent of SERVICE_AGENTS) {
62
- expect(isRealAgent(agent)).toBe(false)
63
- }
64
- })
65
-
66
- it("returns false for service agents (case-insensitive)", () => {
67
- expect(isRealAgent("Title")).toBe(false)
68
- expect(isRealAgent("COMPACTION")).toBe(false)
69
- expect(isRealAgent("Summary")).toBe(false)
70
- expect(isRealAgent("SYSTEM")).toBe(false)
71
- })
72
-
73
- it("returns true for real agents", () => {
74
- expect(isRealAgent("dev")).toBe(true)
75
- expect(isRealAgent("pm")).toBe(true)
76
- expect(isRealAgent("architect")).toBe(true)
77
- expect(isRealAgent("analyst")).toBe(true)
78
- expect(isRealAgent("researcher")).toBe(true)
79
- expect(isRealAgent("coder")).toBe(true)
80
- expect(isRealAgent("reviewer")).toBe(true)
81
- })
82
-
83
- it("returns true for unknown non-service agents", () => {
84
- expect(isRealAgent("custom-agent")).toBe(true)
85
- expect(isRealAgent("myagent")).toBe(true)
86
- })
87
- })
88
-
89
- // =============================================================================
90
- // UNIT TESTS: Constants validation (replicas)
91
- // =============================================================================
92
- describe("constants", () => {
93
- it("SERVICE_AGENTS contains expected entries", () => {
94
- expect(SERVICE_AGENTS).toContain("title")
95
- expect(SERVICE_AGENTS).toContain("compaction")
96
- expect(SERVICE_AGENTS).toContain("summary")
97
- expect(SERVICE_AGENTS).toContain("system")
98
- expect(SERVICE_AGENTS.length).toBe(4)
99
- })
100
-
101
- it("BASE_FILES contains CLAUDE.md and AGENTS.md", () => {
102
- expect(BASE_FILES).toContain("CLAUDE.md")
103
- expect(BASE_FILES).toContain("AGENTS.md")
104
- })
105
-
106
- it("AGENT_FILES covers all known agent types", () => {
107
- const expectedAgents = [
108
- "dev", "coder", "architect", "pm", "analyst", "researcher", "crawler", "change-manager",
109
- ]
110
- for (const agent of expectedAgents) {
111
- expect(AGENT_FILES[agent]).toBeDefined()
112
- expect(AGENT_FILES[agent].length).toBeGreaterThan(0)
113
- }
114
- })
115
-
116
- it("all AGENT_FILES include BASE_FILES", () => {
117
- for (const [_agent, files] of Object.entries(AGENT_FILES)) {
118
- for (const baseFile of BASE_FILES) {
119
- expect(files).toContain(baseFile)
120
- }
121
- }
122
- })
123
-
124
- it("DEFAULT_FILES includes BASE_FILES + prd + architecture", () => {
125
- expect(DEFAULT_FILES).toContain("CLAUDE.md")
126
- expect(DEFAULT_FILES).toContain("AGENTS.md")
127
- expect(DEFAULT_FILES).toContain("docs/prd.md")
128
- expect(DEFAULT_FILES).toContain("docs/architecture.md")
129
- })
130
-
131
- it("MUST_READ_FILES has 'default' key", () => {
132
- expect(MUST_READ_FILES.default).toBeDefined()
133
- expect(MUST_READ_FILES.default.length).toBeGreaterThan(0)
134
- })
135
-
136
- it("MUST_READ_FILES dev includes AGENTS.md and CLAUDE.md", () => {
137
- expect(MUST_READ_FILES.dev).toContain("AGENTS.md")
138
- expect(MUST_READ_FILES.dev).toContain("CLAUDE.md")
139
- })
140
-
141
- it("MUST_READ_FILES dev includes coding-standards", () => {
142
- expect(MUST_READ_FILES.dev).toContain("docs/coding-standards/README.md")
143
- })
144
- })
145
-
146
- // =============================================================================
147
- // INTEGRATION: Plugin hook structure
148
- // =============================================================================
149
- describe("CustomCompactionPlugin: hook structure", () => {
150
- let tempDir: string
151
-
152
- beforeEach(async () => {
153
- tempDir = await createTempDir()
154
- })
155
-
156
- afterEach(async () => {
157
- await cleanupTempDir(tempDir)
158
- })
159
-
160
- it("returns all required hooks", async () => {
161
- const ctx = createMockCtx(tempDir)
162
- const hooks = await CustomCompactionPlugin(ctx as any)
163
-
164
- expect(hooks["chat.message"]).toBeFunction()
165
- expect(hooks["chat.params"]).toBeFunction()
166
- expect(hooks["experimental.session.compacting"]).toBeFunction()
167
- expect(hooks.event).toBeFunction()
168
- })
169
- })
170
-
171
- // =============================================================================
172
- // INTEGRATION: chat.message hook (agent tracking)
173
- // =============================================================================
174
- describe("chat.message hook", () => {
175
- let tempDir: string
176
-
177
- beforeEach(async () => {
178
- tempDir = await createTempDir()
179
- })
180
-
181
- afterEach(async () => {
182
- await cleanupTempDir(tempDir)
183
- })
184
-
185
- it("tracks string agent name", async () => {
186
- const ctx = createMockCtx(tempDir)
187
- const hooks = await CustomCompactionPlugin(ctx as any)
188
-
189
- await hooks["chat.message"]!(
190
- { agent: "dev", sessionID: "sess-1" } as any,
191
- { message: {}, parts: [] } as any
192
- )
193
-
194
- const output = { context: [] as string[], prompt: undefined }
195
- await hooks["experimental.session.compacting"]!(
196
- { sessionID: "sess-1" } as any,
197
- output as any
198
- )
199
-
200
- expect(output.context.length).toBeGreaterThan(0)
201
- const briefing = output.context.join("\n")
202
- expect(briefing).toContain("@dev")
203
- })
204
-
205
- it("tracks object agent with name property", async () => {
206
- const ctx = createMockCtx(tempDir)
207
- const hooks = await CustomCompactionPlugin(ctx as any)
208
-
209
- await hooks["chat.message"]!(
210
- { agent: { name: "architect" }, sessionID: "sess-1" } as any,
211
- { message: {}, parts: [] } as any
212
- )
213
-
214
- const output = { context: [] as string[], prompt: undefined }
215
- await hooks["experimental.session.compacting"]!(
216
- { sessionID: "sess-1" } as any,
217
- output as any
218
- )
219
-
220
- const briefing = output.context.join("\n")
221
- expect(briefing).toContain("@architect")
222
- })
223
-
224
- it("ignores service agents", async () => {
225
- const ctx = createMockCtx(tempDir)
226
- const hooks = await CustomCompactionPlugin(ctx as any)
227
-
228
- // First set a real agent
229
- await hooks["chat.message"]!(
230
- { agent: "dev", sessionID: "sess-1" } as any,
231
- { message: {}, parts: [] } as any
232
- )
233
-
234
- // Then send service agent - should NOT override
235
- await hooks["chat.message"]!(
236
- { agent: "compaction", sessionID: "sess-1" } as any,
237
- { message: {}, parts: [] } as any
238
- )
239
-
240
- const output = { context: [] as string[], prompt: undefined }
241
- await hooks["experimental.session.compacting"]!(
242
- { sessionID: "sess-1" } as any,
243
- output as any
244
- )
245
-
246
- const briefing = output.context.join("\n")
247
- expect(briefing).toContain("@dev")
248
- expect(briefing).not.toContain("@compaction")
249
- })
250
-
251
- it("handles null agent without crashing", async () => {
252
- const ctx = createMockCtx(tempDir)
253
- const hooks = await CustomCompactionPlugin(ctx as any)
254
-
255
- await hooks["chat.message"]!(
256
- { agent: null, sessionID: "sess-1" } as any,
257
- { message: {}, parts: [] } as any
258
- )
259
- // Should not throw
260
- })
261
-
262
- it("handles missing agent field", async () => {
263
- const ctx = createMockCtx(tempDir)
264
- const hooks = await CustomCompactionPlugin(ctx as any)
265
-
266
- await hooks["chat.message"]!(
267
- { sessionID: "sess-1" } as any,
268
- { message: {}, parts: [] } as any
269
- )
270
- // Should not throw
271
- })
272
- })
273
-
274
- // =============================================================================
275
- // INTEGRATION: chat.params hook (agent tracking backup)
276
- // =============================================================================
277
- describe("chat.params hook", () => {
278
- let tempDir: string
279
-
280
- beforeEach(async () => {
281
- tempDir = await createTempDir()
282
- })
283
-
284
- afterEach(async () => {
285
- await cleanupTempDir(tempDir)
286
- })
287
-
288
- it("tracks agent from params as backup", async () => {
289
- const ctx = createMockCtx(tempDir)
290
- const hooks = await CustomCompactionPlugin(ctx as any)
291
-
292
- await hooks["chat.params"]!(
293
- { agent: "pm", sessionID: "sess-1", model: {}, provider: {}, message: {} } as any,
294
- { temperature: 0.3, topP: 1, topK: 0, options: {} } as any
295
- )
296
-
297
- const output = { context: [] as string[], prompt: undefined }
298
- await hooks["experimental.session.compacting"]!(
299
- { sessionID: "sess-1" } as any,
300
- output as any
301
- )
302
-
303
- const briefing = output.context.join("\n")
304
- expect(briefing).toContain("@pm")
305
- })
306
-
307
- it("ignores service agents in params", async () => {
308
- const ctx = createMockCtx(tempDir)
309
- const hooks = await CustomCompactionPlugin(ctx as any)
310
-
311
- await hooks["chat.params"]!(
312
- { agent: "title", sessionID: "sess-1", model: {}, provider: {}, message: {} } as any,
313
- { temperature: 0.3, topP: 1, topK: 0, options: {} } as any
314
- )
315
-
316
- const output = { context: [] as string[], prompt: undefined }
317
- await hooks["experimental.session.compacting"]!(
318
- { sessionID: "sess-1" } as any,
319
- output as any
320
- )
321
-
322
- expect(output.context.length).toBeGreaterThan(0)
323
- const briefing = output.context.join("\n")
324
- expect(briefing).not.toContain("@title")
325
- })
326
- })
327
-
328
- // =============================================================================
329
- // INTEGRATION: experimental.session.compacting hook
330
- // =============================================================================
331
- describe("compaction hook", () => {
332
- let tempDir: string
333
-
334
- beforeEach(async () => {
335
- tempDir = await createTempDir()
336
- })
337
-
338
- afterEach(async () => {
339
- await cleanupTempDir(tempDir)
340
- })
341
-
342
- it("pushes context to output.context array", async () => {
343
- const ctx = createMockCtx(tempDir)
344
- const hooks = await CustomCompactionPlugin(ctx as any)
345
-
346
- const output = { context: [] as string[], prompt: undefined }
347
- await hooks["experimental.session.compacting"]!(
348
- { sessionID: "sess-1" } as any,
349
- output as any
350
- )
351
-
352
- expect(output.context.length).toBeGreaterThan(0)
353
- expect(typeof output.context[0]).toBe("string")
354
- })
355
-
356
- it("includes read commands in output", async () => {
357
- const ctx = createMockCtx(tempDir)
358
- const hooks = await CustomCompactionPlugin(ctx as any)
359
-
360
- const output = { context: [] as string[], prompt: undefined }
361
- await hooks["experimental.session.compacting"]!(
362
- { sessionID: "sess-1" } as any,
363
- output as any
364
- )
365
-
366
- const fullContext = output.context.join("\n")
367
- expect(fullContext).toContain("Read")
368
- expect(fullContext).toContain("AGENTS.md")
369
- })
370
-
371
- it("includes DO NOT ask instruction", async () => {
372
- const ctx = createMockCtx(tempDir)
373
- const hooks = await CustomCompactionPlugin(ctx as any)
374
-
375
- const output = { context: [] as string[], prompt: undefined }
376
- await hooks["experimental.session.compacting"]!(
377
- { sessionID: "sess-1" } as any,
378
- output as any
379
- )
380
-
381
- const fullContext = output.context.join("\n")
382
- expect(fullContext).toContain("DO NOT ask user")
383
- })
384
-
385
- it("includes session state when available", async () => {
386
- const dir = await createTempDir({
387
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
388
- ".opencode/state/todos.json": FIXTURE_TODOS,
389
- })
390
-
391
- const ctx = createMockCtx(dir)
392
- const hooks = await CustomCompactionPlugin(ctx as any)
393
-
394
- await hooks["chat.message"]!(
395
- { agent: "dev", sessionID: "sess-1" } as any,
396
- { message: {}, parts: [] } as any
397
- )
398
-
399
- const output = { context: [] as string[], prompt: undefined }
400
- await hooks["experimental.session.compacting"]!(
401
- { sessionID: "sess-1" } as any,
402
- output as any
403
- )
404
-
405
- const briefing = output.context.join("\n")
406
- expect(briefing).toContain("AUTH-E01")
407
- expect(briefing).toContain("AUTH-S01-03")
408
- expect(briefing).toContain("/dev-epic")
409
-
410
- await cleanupTempDir(dir)
411
- })
412
-
413
- it("formats dev context with story info from session state", async () => {
414
- const storyPath = "docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md"
415
- const dir = await createTempDir({
416
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
417
- ".opencode/state/todos.json": FIXTURE_TODOS,
418
- [storyPath]: FIXTURE_STORY_MD,
419
- })
420
-
421
- const ctx = createMockCtx(dir)
422
- const hooks = await CustomCompactionPlugin(ctx as any)
423
-
424
- await hooks["chat.message"]!(
425
- { agent: "dev", sessionID: "sess-1" } as any,
426
- { message: {}, parts: [] } as any
427
- )
428
-
429
- const output = { context: [] as string[], prompt: undefined }
430
- await hooks["experimental.session.compacting"]!(
431
- { sessionID: "sess-1" } as any,
432
- output as any
433
- )
434
-
435
- const briefing = output.context.join("\n")
436
- expect(briefing).toContain("T2")
437
- expect(briefing).toContain("T1")
438
- expect(briefing).toContain("session-state.yaml")
439
-
440
- await cleanupTempDir(dir)
441
- })
442
-
443
- it("pushes context AND instructions (fix #5 verification)", async () => {
444
- const dir = await createTempDir({
445
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
446
- ".opencode/state/todos.json": FIXTURE_TODOS,
447
- })
448
-
449
- const ctx = createMockCtx(dir)
450
- const hooks = await CustomCompactionPlugin(ctx as any)
451
-
452
- await hooks["chat.message"]!(
453
- { agent: "dev", sessionID: "sess-1" } as any,
454
- { message: {}, parts: [] } as any
455
- )
456
-
457
- const output = { context: [] as string[], prompt: undefined }
458
- await hooks["experimental.session.compacting"]!(
459
- { sessionID: "sess-1" } as any,
460
- output as any
461
- )
462
-
463
- // After fix #5: should have briefing + context + instructions = 3 items
464
- expect(output.context.length).toBeGreaterThanOrEqual(2)
465
- // The full output should contain both agent-specific context and resume instructions
466
- const fullContext = output.context.join("\n")
467
- // Context should have session state info
468
- expect(fullContext).toContain("Session State")
469
- // Instructions should have resume protocol
470
- expect(fullContext).toContain("IN PROGRESS")
471
-
472
- await cleanupTempDir(dir)
473
- })
474
-
475
- it("falls back gracefully when no session state exists", async () => {
476
- const ctx = createMockCtx(tempDir)
477
- const hooks = await CustomCompactionPlugin(ctx as any)
478
-
479
- await hooks["chat.message"]!(
480
- { agent: "dev", sessionID: "sess-1" } as any,
481
- { message: {}, parts: [] } as any
482
- )
483
-
484
- const output = { context: [] as string[], prompt: undefined }
485
- await hooks["experimental.session.compacting"]!(
486
- { sessionID: "sess-1" } as any,
487
- output as any
488
- )
489
-
490
- expect(output.context.length).toBeGreaterThan(0)
491
- const briefing = output.context.join("\n")
492
- expect(briefing).toContain("@dev")
493
- expect(briefing).toContain("Read")
494
- })
495
-
496
- it("formats architect-specific context", async () => {
497
- const ctx = createMockCtx(tempDir)
498
- const hooks = await CustomCompactionPlugin(ctx as any)
499
-
500
- await hooks["chat.message"]!(
501
- { agent: "architect", sessionID: "sess-1" } as any,
502
- { message: {}, parts: [] } as any
503
- )
504
-
505
- const output = { context: [] as string[], prompt: undefined }
506
- await hooks["experimental.session.compacting"]!(
507
- { sessionID: "sess-1" } as any,
508
- output as any
509
- )
510
-
511
- const briefing = output.context.join("\n")
512
- expect(briefing).toContain("@architect")
513
- })
514
-
515
- it("formats PM-specific context", async () => {
516
- const ctx = createMockCtx(tempDir)
517
- const hooks = await CustomCompactionPlugin(ctx as any)
518
-
519
- await hooks["chat.message"]!(
520
- { agent: "pm", sessionID: "sess-1" } as any,
521
- { message: {}, parts: [] } as any
522
- )
523
-
524
- const output = { context: [] as string[], prompt: undefined }
525
- await hooks["experimental.session.compacting"]!(
526
- { sessionID: "sess-1" } as any,
527
- output as any
528
- )
529
-
530
- const briefing = output.context.join("\n")
531
- expect(briefing).toContain("@pm")
532
- })
533
-
534
- it("handles unknown agent with default context", async () => {
535
- const ctx = createMockCtx(tempDir)
536
- const hooks = await CustomCompactionPlugin(ctx as any)
537
-
538
- await hooks["chat.message"]!(
539
- { agent: "custom-agent", sessionID: "sess-1" } as any,
540
- { message: {}, parts: [] } as any
541
- )
542
-
543
- const output = { context: [] as string[], prompt: undefined }
544
- await hooks["experimental.session.compacting"]!(
545
- { sessionID: "sess-1" } as any,
546
- output as any
547
- )
548
-
549
- expect(output.context.length).toBeGreaterThan(0)
550
- })
551
- })
552
-
553
- // =============================================================================
554
- // INTEGRATION: event hook
555
- // =============================================================================
556
- describe("event hook", () => {
557
- let tempDir: string
558
-
559
- beforeEach(async () => {
560
- tempDir = await createTempDir()
561
- })
562
-
563
- afterEach(async () => {
564
- await cleanupTempDir(tempDir)
565
- })
566
-
567
- it("handles session.idle event without error", async () => {
568
- const ctx = createMockCtx(tempDir)
569
- const hooks = await CustomCompactionPlugin(ctx as any)
570
-
571
- await hooks.event!({ event: { type: "session.idle" } as any })
572
- })
573
-
574
- it("handles unknown event types gracefully", async () => {
575
- const ctx = createMockCtx(tempDir)
576
- const hooks = await CustomCompactionPlugin(ctx as any)
577
-
578
- await hooks.event!({ event: { type: "unknown.event" } as any })
579
- await hooks.event!({ event: { type: "todo.updated" } as any })
580
- await hooks.event!({ event: { type: "file.edited" } as any })
581
- })
582
- })
583
-
584
- // =============================================================================
585
- // INTEGRATION: Epic state parsing (via compaction hook)
586
- // =============================================================================
587
- describe("epic state parsing", () => {
588
- it("parses active epic state from sprint directory", async () => {
589
- const dir = await createTempDir({
590
- "docs/sprint-artifacts/sprint-1/.sprint-state/epic-01-state.yaml": FIXTURE_EPIC_STATE,
591
- ".opencode/state/todos.json": "[]",
592
- })
593
-
594
- const ctx = createMockCtx(dir)
595
- const hooks = await CustomCompactionPlugin(ctx as any)
596
-
597
- await hooks["chat.message"]!(
598
- { agent: "dev", sessionID: "sess-1" } as any,
599
- { message: {}, parts: [] } as any
600
- )
601
-
602
- const output = { context: [] as string[], prompt: undefined }
603
- await hooks["experimental.session.compacting"]!(
604
- { sessionID: "sess-1" } as any,
605
- output as any
606
- )
607
-
608
- const briefing = output.context.join("\n")
609
- expect(briefing).toContain("epic-01-state.yaml")
610
- expect(briefing).toContain("@dev")
611
-
612
- await cleanupTempDir(dir)
613
- })
614
-
615
- it("prefers session-state.yaml over epic state file", async () => {
616
- const dir = await createTempDir({
617
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
618
- "docs/sprint-artifacts/sprint-1/.sprint-state/epic-01-state.yaml": FIXTURE_EPIC_STATE,
619
- ".opencode/state/todos.json": FIXTURE_TODOS,
620
- })
621
-
622
- const ctx = createMockCtx(dir)
623
- const hooks = await CustomCompactionPlugin(ctx as any)
624
-
625
- await hooks["chat.message"]!(
626
- { agent: "dev", sessionID: "sess-1" } as any,
627
- { message: {}, parts: [] } as any
628
- )
629
-
630
- const output = { context: [] as string[], prompt: undefined }
631
- await hooks["experimental.session.compacting"]!(
632
- { sessionID: "sess-1" } as any,
633
- output as any
634
- )
635
-
636
- const briefing = output.context.join("\n")
637
- expect(briefing).toContain("/dev-epic")
638
- expect(briefing).toContain("AUTH-S01-03")
639
-
640
- await cleanupTempDir(dir)
641
- })
642
- })
643
-
644
- // =============================================================================
645
- // INTEGRATION: TODO list in compaction
646
- // =============================================================================
647
- describe("TODO list integration", () => {
648
- let tempDir: string
649
-
650
- beforeEach(async () => {
651
- tempDir = await createTempDir()
652
- })
653
-
654
- afterEach(async () => {
655
- await cleanupTempDir(tempDir)
656
- })
657
-
658
- it("includes TODO status in compaction output", async () => {
659
- const dir = await createTempDir({
660
- ".opencode/state/todos.json": FIXTURE_TODOS,
661
- })
662
-
663
- const ctx = createMockCtx(dir)
664
- const hooks = await CustomCompactionPlugin(ctx as any)
665
-
666
- await hooks["chat.message"]!(
667
- { agent: "dev", sessionID: "sess-1" } as any,
668
- { message: {}, parts: [] } as any
669
- )
670
-
671
- const output = { context: [] as string[], prompt: undefined }
672
- await hooks["experimental.session.compacting"]!(
673
- { sessionID: "sess-1" } as any,
674
- output as any
675
- )
676
-
677
- const briefing = output.context.join("\n")
678
- expect(briefing).toContain("TODO")
679
- expect(briefing).toMatch(/\d+ done/)
680
- expect(briefing).toMatch(/\d+ in progress/)
681
-
682
- await cleanupTempDir(dir)
683
- })
684
-
685
- it("handles empty TODO list gracefully", async () => {
686
- const dir = await createTempDir({
687
- ".opencode/state/todos.json": "[]",
688
- })
689
-
690
- const ctx = createMockCtx(dir)
691
- const hooks = await CustomCompactionPlugin(ctx as any)
692
-
693
- const output = { context: [] as string[], prompt: undefined }
694
- await hooks["experimental.session.compacting"]!(
695
- { sessionID: "sess-1" } as any,
696
- output as any
697
- )
698
-
699
- expect(output.context.length).toBeGreaterThan(0)
700
-
701
- await cleanupTempDir(dir)
702
- })
703
-
704
- it("handles missing TODO file gracefully", async () => {
705
- const ctx = createMockCtx(tempDir)
706
- const hooks = await CustomCompactionPlugin(ctx as any)
707
-
708
- const output = { context: [] as string[], prompt: undefined }
709
- await hooks["experimental.session.compacting"]!(
710
- { sessionID: "sess-1" } as any,
711
- output as any
712
- )
713
-
714
- expect(output.context.length).toBeGreaterThan(0)
715
- })
716
- })
717
-
718
- // =============================================================================
719
- // MEMORY SAFETY
720
- // =============================================================================
721
- describe("memory safety", () => {
722
- let tempDir: string
723
-
724
- beforeEach(async () => {
725
- tempDir = await createTempDir()
726
- })
727
-
728
- afterEach(async () => {
729
- await cleanupTempDir(tempDir)
730
- })
731
-
732
- it("agent tracking overwrites, doesn't accumulate", async () => {
733
- const ctx = createMockCtx(tempDir)
734
- const hooks = await CustomCompactionPlugin(ctx as any)
735
-
736
- // Send 1000 different agents - should only track the latest
737
- for (let i = 0; i < 1000; i++) {
738
- await hooks["chat.message"]!(
739
- { agent: `agent-${i}`, sessionID: "sess-1" } as any,
740
- { message: {}, parts: [] } as any
741
- )
742
- }
743
-
744
- const output = { context: [] as string[], prompt: undefined }
745
- await hooks["experimental.session.compacting"]!(
746
- { sessionID: "sess-1" } as any,
747
- output as any
748
- )
749
-
750
- const briefing = output.context.join("\n")
751
- expect(briefing).toContain("@agent-999")
752
- expect(briefing).not.toContain("@agent-0")
753
- expect(briefing).not.toContain("@agent-500")
754
- })
755
-
756
- it("repeated compaction doesn't leak context", async () => {
757
- const ctx = createMockCtx(tempDir)
758
- const hooks = await CustomCompactionPlugin(ctx as any)
759
-
760
- await hooks["chat.message"]!(
761
- { agent: "dev", sessionID: "sess-1" } as any,
762
- { message: {}, parts: [] } as any
763
- )
764
-
765
- // Run compaction 100 times with FRESH output each time
766
- for (let i = 0; i < 100; i++) {
767
- const output = { context: [] as string[], prompt: undefined }
768
- await hooks["experimental.session.compacting"]!(
769
- { sessionID: "sess-1" } as any,
770
- output as any
771
- )
772
-
773
- // Each compaction should produce a bounded number of context entries
774
- // (briefing + context + instructions = up to 3)
775
- expect(output.context.length).toBeGreaterThanOrEqual(1)
776
- expect(output.context.length).toBeLessThanOrEqual(3)
777
- }
778
- })
779
-
780
- it("compaction doesn't grow output.context with repeated calls", async () => {
781
- const ctx = createMockCtx(tempDir)
782
- const hooks = await CustomCompactionPlugin(ctx as any)
783
-
784
- const output = { context: [] as string[], prompt: undefined }
785
-
786
- const CALLS = 10
787
- for (let i = 0; i < CALLS; i++) {
788
- await hooks["experimental.session.compacting"]!(
789
- { sessionID: "sess-1" } as any,
790
- output as any
791
- )
792
- }
793
-
794
- // The plugin pushes up to 3 items per call (briefing + context + instructions)
795
- const itemsPerCall = output.context.length / CALLS
796
- expect(itemsPerCall).toBeGreaterThanOrEqual(1)
797
- expect(itemsPerCall).toBeLessThanOrEqual(3)
798
- })
799
-
800
- it("multiple plugin instances don't leak memory", async () => {
801
- const memBefore = process.memoryUsage().heapUsed
802
-
803
- for (let i = 0; i < 50; i++) {
804
- const dir = await createTempDir()
805
- const ctx = createMockCtx(dir)
806
- const hooks = await CustomCompactionPlugin(ctx as any)
807
-
808
- await hooks["chat.message"]!(
809
- { agent: "dev", sessionID: `sess-${i}` } as any,
810
- { message: {}, parts: [] } as any
811
- )
812
- const output = { context: [] as string[], prompt: undefined }
813
- await hooks["experimental.session.compacting"]!(
814
- { sessionID: `sess-${i}` } as any,
815
- output as any
816
- )
817
- await hooks.event!({ event: { type: "session.idle" } as any })
818
-
819
- await cleanupTempDir(dir)
820
- }
821
-
822
- if ((globalThis as any).gc) (globalThis as any).gc()
823
-
824
- const memAfter = process.memoryUsage().heapUsed
825
- const growthMB = (memAfter - memBefore) / 1024 / 1024
826
-
827
- expect(growthMB).toBeLessThan(100)
828
- })
829
- })