@comfanion/workflow 4.38.1-dev.9 → 4.38.1
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/bin/cli.js +7 -0
- package/package.json +1 -1
- package/src/build-info.json +3 -2
- package/src/opencode/agents/reviewer.md +17 -193
- package/src/opencode/gitignore +28 -0
- package/src/opencode/package.json +5 -1
- package/src/opencode/plugins/__tests__/custom-compaction.test.ts +829 -0
- package/src/opencode/plugins/__tests__/file-indexer.test.ts +425 -0
- package/src/opencode/plugins/__tests__/helpers/mock-ctx.ts +171 -0
- package/src/opencode/plugins/__tests__/leak-stress.test.ts +315 -0
- package/src/opencode/plugins/__tests__/version-check.test.ts +223 -0
- package/src/opencode/plugins/custom-compaction.ts +29 -9
- package/src/opencode/plugins/file-indexer.ts +79 -54
- package/src/opencode/plugins/version-check.ts +55 -14
- package/src/opencode/skills/code-review/SKILL.md +165 -38
- package/src/opencode/skills/dev-epic/SKILL.md +28 -16
- package/src/opencode/skills/dev-sprint/SKILL.md +26 -11
- package/src/opencode/skills/dev-story/SKILL.md +13 -0
- package/src/opencode/skills/prd-writing/SKILL.md +28 -8
- package/src/opencode/skills/prd-writing/template.md +36 -17
- package/src/opencode/skills/story-writing/template.md +8 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Leak Stress Tests
|
|
3
|
+
*
|
|
4
|
+
* Goal: prove the 3 plugins are NOT the source of a memory leak.
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* 1. Run each plugin's hot path N times in a tight loop
|
|
8
|
+
* 2. Measure heap snapshots at intervals
|
|
9
|
+
* 3. Assert heap growth is sub-linear (flat / bounded)
|
|
10
|
+
* 4. Specifically test known risk areas:
|
|
11
|
+
* - custom-compaction: closure variables (lastActiveAgent, lastSessionId)
|
|
12
|
+
* - file-indexer: module-level pendingFiles Map
|
|
13
|
+
* - version-check: stateless (baseline)
|
|
14
|
+
*
|
|
15
|
+
* NOTE: Tests use only the public plugin interface (no internal imports).
|
|
16
|
+
*
|
|
17
|
+
* Run only leak tests:
|
|
18
|
+
* bun test plugins/__tests__/leak-stress.test.ts
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
21
|
+
import { join } from "path"
|
|
22
|
+
import {
|
|
23
|
+
createMockCtx,
|
|
24
|
+
createTempDir,
|
|
25
|
+
cleanupTempDir,
|
|
26
|
+
FIXTURE_SESSION_STATE,
|
|
27
|
+
FIXTURE_STORY_MD,
|
|
28
|
+
FIXTURE_EPIC_STATE,
|
|
29
|
+
FIXTURE_TODOS,
|
|
30
|
+
FIXTURE_CONFIG_YAML,
|
|
31
|
+
} from "./helpers/mock-ctx"
|
|
32
|
+
import { CustomCompactionPlugin } from "../custom-compaction"
|
|
33
|
+
import { FileIndexerPlugin } from "../file-indexer"
|
|
34
|
+
import { VersionCheckPlugin } from "../version-check"
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
function heapMB(): number {
|
|
40
|
+
return process.memoryUsage().heapUsed / 1024 / 1024
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tryGC(): void {
|
|
44
|
+
if (typeof globalThis.gc === "function") {
|
|
45
|
+
globalThis.gc()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Take N heap snapshots at equal intervals during a callback. */
|
|
50
|
+
async function profile(
|
|
51
|
+
iterations: number,
|
|
52
|
+
snapshots: number,
|
|
53
|
+
fn: (i: number) => Promise<void>,
|
|
54
|
+
): Promise<number[]> {
|
|
55
|
+
const interval = Math.floor(iterations / snapshots)
|
|
56
|
+
const heaps: number[] = []
|
|
57
|
+
|
|
58
|
+
tryGC()
|
|
59
|
+
heaps.push(heapMB())
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < iterations; i++) {
|
|
62
|
+
await fn(i)
|
|
63
|
+
if ((i + 1) % interval === 0) {
|
|
64
|
+
tryGC()
|
|
65
|
+
heaps.push(heapMB())
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return heaps
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check that heap growth is bounded:
|
|
74
|
+
* last snapshot - first snapshot < maxGrowthMB.
|
|
75
|
+
*/
|
|
76
|
+
function assertBoundedGrowth(
|
|
77
|
+
heaps: number[],
|
|
78
|
+
maxGrowthMB: number,
|
|
79
|
+
label: string,
|
|
80
|
+
): void {
|
|
81
|
+
const growth = heaps[heaps.length - 1] - heaps[0]
|
|
82
|
+
const peak = Math.max(...heaps)
|
|
83
|
+
const valley = Math.min(...heaps)
|
|
84
|
+
|
|
85
|
+
console.log(
|
|
86
|
+
` [${label}] heap snapshots (MB): ${heaps.map((h) => h.toFixed(1)).join(" -> ")}`,
|
|
87
|
+
)
|
|
88
|
+
console.log(
|
|
89
|
+
` [${label}] growth: ${growth.toFixed(1)} MB, peak: ${peak.toFixed(1)} MB, valley: ${valley.toFixed(1)} MB`,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(growth).toBeLessThan(maxGrowthMB)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// 1. CUSTOM COMPACTION -- hot path: chat.message + compaction
|
|
97
|
+
// =============================================================================
|
|
98
|
+
describe("leak: custom-compaction", () => {
|
|
99
|
+
const ITERATIONS = 500
|
|
100
|
+
const SNAPSHOTS = 5
|
|
101
|
+
const MAX_GROWTH_MB = 30
|
|
102
|
+
|
|
103
|
+
it("chat.message + compaction cycle doesn't leak", async () => {
|
|
104
|
+
const dir = await createTempDir({
|
|
105
|
+
".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
|
|
106
|
+
".opencode/state/todos.json": FIXTURE_TODOS,
|
|
107
|
+
"docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
|
|
108
|
+
FIXTURE_STORY_MD,
|
|
109
|
+
})
|
|
110
|
+
const ctx = createMockCtx(dir)
|
|
111
|
+
const hooks = await CustomCompactionPlugin(ctx as any)
|
|
112
|
+
|
|
113
|
+
const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
|
|
114
|
+
await hooks["chat.message"]!(
|
|
115
|
+
{ agent: "dev", sessionID: `s-${i}` } as any,
|
|
116
|
+
{ message: {}, parts: [] } as any,
|
|
117
|
+
)
|
|
118
|
+
const output = { context: [] as string[], prompt: undefined }
|
|
119
|
+
await hooks["experimental.session.compacting"]!(
|
|
120
|
+
{ sessionID: `s-${i}` } as any,
|
|
121
|
+
output as any,
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
assertBoundedGrowth(heaps, MAX_GROWTH_MB, "compaction-cycle")
|
|
126
|
+
await cleanupTempDir(dir)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("chat.message rapid-fire doesn't accumulate strings", async () => {
|
|
130
|
+
const dir = await createTempDir()
|
|
131
|
+
const ctx = createMockCtx(dir)
|
|
132
|
+
const hooks = await CustomCompactionPlugin(ctx as any)
|
|
133
|
+
|
|
134
|
+
const heaps = await profile(2000, SNAPSHOTS, async (i) => {
|
|
135
|
+
await hooks["chat.message"]!(
|
|
136
|
+
{ agent: `agent-${i % 50}`, sessionID: `s-${i}` } as any,
|
|
137
|
+
{ message: {}, parts: [] } as any,
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
assertBoundedGrowth(heaps, MAX_GROWTH_MB, "chat.message-rapid")
|
|
142
|
+
await cleanupTempDir(dir)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("event hook doesn't leak on repeated session.idle", async () => {
|
|
146
|
+
const dir = await createTempDir()
|
|
147
|
+
const ctx = createMockCtx(dir)
|
|
148
|
+
const hooks = await CustomCompactionPlugin(ctx as any)
|
|
149
|
+
|
|
150
|
+
const heaps = await profile(1000, SNAPSHOTS, async () => {
|
|
151
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
assertBoundedGrowth(heaps, MAX_GROWTH_MB, "event-idle")
|
|
155
|
+
await cleanupTempDir(dir)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("plugin factory doesn't leak across many instances", async () => {
|
|
159
|
+
const heaps = await profile(100, SNAPSHOTS, async (i) => {
|
|
160
|
+
const dir = await createTempDir()
|
|
161
|
+
const ctx = createMockCtx(dir)
|
|
162
|
+
const hooks = await CustomCompactionPlugin(ctx as any)
|
|
163
|
+
await hooks["chat.message"]!(
|
|
164
|
+
{ agent: "dev", sessionID: `s-${i}` } as any,
|
|
165
|
+
{ message: {}, parts: [] } as any,
|
|
166
|
+
)
|
|
167
|
+
const output = { context: [] as string[], prompt: undefined }
|
|
168
|
+
await hooks["experimental.session.compacting"]!(
|
|
169
|
+
{ sessionID: `s-${i}` } as any,
|
|
170
|
+
output as any,
|
|
171
|
+
)
|
|
172
|
+
await cleanupTempDir(dir)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
assertBoundedGrowth(heaps, 50, "compaction-instances")
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// 2. FILE INDEXER -- hot path: event handler (file.edited queueing)
|
|
181
|
+
// =============================================================================
|
|
182
|
+
describe("leak: file-indexer", () => {
|
|
183
|
+
const ITERATIONS = 1000
|
|
184
|
+
const SNAPSHOTS = 5
|
|
185
|
+
const MAX_GROWTH_MB = 30
|
|
186
|
+
|
|
187
|
+
it("file.edited event handler doesn't leak (cycling file names)", async () => {
|
|
188
|
+
const dir = await createTempDir({
|
|
189
|
+
".opencode/config.yaml": FIXTURE_CONFIG_YAML,
|
|
190
|
+
})
|
|
191
|
+
const ctx = createMockCtx(dir)
|
|
192
|
+
const hooks = await FileIndexerPlugin(ctx as any)
|
|
193
|
+
|
|
194
|
+
const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
|
|
195
|
+
await hooks.event!({
|
|
196
|
+
event: {
|
|
197
|
+
type: "file.edited",
|
|
198
|
+
properties: { file: join(dir, `src/file-${i % 50}.ts`) },
|
|
199
|
+
} as any,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
assertBoundedGrowth(heaps, MAX_GROWTH_MB, "file-edited-events")
|
|
204
|
+
await cleanupTempDir(dir)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("disabled plugin doesn't accumulate anything", async () => {
|
|
208
|
+
const dir = await createTempDir({
|
|
209
|
+
".opencode/config.yaml": `vectorizer:\n enabled: false\n auto_index: false\n`,
|
|
210
|
+
})
|
|
211
|
+
const ctx = createMockCtx(dir)
|
|
212
|
+
const hooks = await FileIndexerPlugin(ctx as any)
|
|
213
|
+
|
|
214
|
+
const heaps = await profile(2000, SNAPSHOTS, async () => {
|
|
215
|
+
await hooks.event!({
|
|
216
|
+
event: {
|
|
217
|
+
type: "file.edited",
|
|
218
|
+
properties: { file: join(dir, "src/app.ts") },
|
|
219
|
+
} as any,
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
assertBoundedGrowth(heaps, 10, "disabled-plugin")
|
|
224
|
+
await cleanupTempDir(dir)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// =============================================================================
|
|
229
|
+
// 3. VERSION CHECK -- hot path: event handler (no-op)
|
|
230
|
+
// =============================================================================
|
|
231
|
+
describe("leak: version-check", () => {
|
|
232
|
+
const ITERATIONS = 2000
|
|
233
|
+
const SNAPSHOTS = 5
|
|
234
|
+
const MAX_GROWTH_MB = 10
|
|
235
|
+
|
|
236
|
+
it("no-op event handler doesn't leak", async () => {
|
|
237
|
+
const dir = await createTempDir()
|
|
238
|
+
const ctx = createMockCtx(dir)
|
|
239
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
240
|
+
|
|
241
|
+
const heaps = await profile(ITERATIONS, SNAPSHOTS, async () => {
|
|
242
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
assertBoundedGrowth(heaps, MAX_GROWTH_MB, "version-noop")
|
|
246
|
+
await cleanupTempDir(dir)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("multiple plugin instances don't leak", async () => {
|
|
250
|
+
const heaps = await profile(100, SNAPSHOTS, async () => {
|
|
251
|
+
const dir = await createTempDir()
|
|
252
|
+
const ctx = createMockCtx(dir)
|
|
253
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
254
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
255
|
+
await cleanupTempDir(dir)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
assertBoundedGrowth(heaps, 30, "version-instances")
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// =============================================================================
|
|
263
|
+
// 4. COMBINED -- simulate a realistic session with all 3 plugins
|
|
264
|
+
// =============================================================================
|
|
265
|
+
describe("leak: combined realistic session", () => {
|
|
266
|
+
it("all 3 plugins running together don't leak", async () => {
|
|
267
|
+
const dir = await createTempDir({
|
|
268
|
+
".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
|
|
269
|
+
".opencode/state/todos.json": FIXTURE_TODOS,
|
|
270
|
+
".opencode/config.yaml": FIXTURE_CONFIG_YAML,
|
|
271
|
+
"docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
|
|
272
|
+
FIXTURE_STORY_MD,
|
|
273
|
+
})
|
|
274
|
+
const ctx = createMockCtx(dir)
|
|
275
|
+
|
|
276
|
+
const [compaction, indexer, versionCheck] = await Promise.all([
|
|
277
|
+
CustomCompactionPlugin(ctx as any),
|
|
278
|
+
FileIndexerPlugin(ctx as any),
|
|
279
|
+
VersionCheckPlugin(ctx as any),
|
|
280
|
+
])
|
|
281
|
+
|
|
282
|
+
const heaps = await profile(300, 5, async (i) => {
|
|
283
|
+
// Simulate realistic session cycle:
|
|
284
|
+
// 1. Agent sends message
|
|
285
|
+
await compaction["chat.message"]!(
|
|
286
|
+
{ agent: "dev", sessionID: `s-${i}` } as any,
|
|
287
|
+
{ message: {}, parts: [] } as any,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
// 2. File gets edited
|
|
291
|
+
await indexer.event!({
|
|
292
|
+
event: {
|
|
293
|
+
type: "file.edited",
|
|
294
|
+
properties: { file: join(dir, `src/module-${i % 20}.ts`) },
|
|
295
|
+
} as any,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// 3. Session idle
|
|
299
|
+
await compaction.event!({ event: { type: "session.idle" } as any })
|
|
300
|
+
await versionCheck.event!({ event: { type: "session.idle" } as any })
|
|
301
|
+
|
|
302
|
+
// 4. Every 50 iterations, simulate compaction
|
|
303
|
+
if (i % 50 === 0) {
|
|
304
|
+
const output = { context: [] as string[], prompt: undefined }
|
|
305
|
+
await compaction["experimental.session.compacting"]!(
|
|
306
|
+
{ sessionID: `s-${i}` } as any,
|
|
307
|
+
output as any,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
assertBoundedGrowth(heaps, 40, "combined-session")
|
|
313
|
+
await cleanupTempDir(dir)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { writeFile } from "fs/promises"
|
|
4
|
+
import { VersionCheckPlugin } from "../version-check"
|
|
5
|
+
import { createMockCtx, createTempDir, cleanupTempDir } from "./helpers/mock-ctx"
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// LOCAL REPLICAS of internal functions (not exported from plugin)
|
|
9
|
+
// These mirror the source logic so we can unit-test the algorithms directly.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
/** Replica of compareVersions from version-check.ts (with pre-release strip fix) */
|
|
13
|
+
function compareVersions(local: string, latest: string): number {
|
|
14
|
+
const strip = (v: string) => v.replace(/-.*$/, '')
|
|
15
|
+
const localParts = strip(local).split('.').map(Number)
|
|
16
|
+
const latestParts = strip(latest).split('.').map(Number)
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < 3; i++) {
|
|
19
|
+
const l = localParts[i] || 0
|
|
20
|
+
const r = latestParts[i] || 0
|
|
21
|
+
if (l < r) return -1
|
|
22
|
+
if (l > r) return 1
|
|
23
|
+
}
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// UNIT TESTS: compareVersions (replica)
|
|
29
|
+
// =============================================================================
|
|
30
|
+
describe("compareVersions", () => {
|
|
31
|
+
it("returns 0 for equal versions", () => {
|
|
32
|
+
expect(compareVersions("1.0.0", "1.0.0")).toBe(0)
|
|
33
|
+
expect(compareVersions("4.36.21", "4.36.21")).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("returns -1 when local < latest (patch)", () => {
|
|
37
|
+
expect(compareVersions("1.0.0", "1.0.1")).toBe(-1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("returns -1 when local < latest (minor)", () => {
|
|
41
|
+
expect(compareVersions("1.0.0", "1.1.0")).toBe(-1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("returns -1 when local < latest (major)", () => {
|
|
45
|
+
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("returns 1 when local > latest (patch)", () => {
|
|
49
|
+
expect(compareVersions("1.0.2", "1.0.1")).toBe(1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns 1 when local > latest (minor)", () => {
|
|
53
|
+
expect(compareVersions("1.2.0", "1.1.9")).toBe(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("returns 1 when local > latest (major)", () => {
|
|
57
|
+
expect(compareVersions("3.0.0", "2.99.99")).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("handles versions with missing parts", () => {
|
|
61
|
+
expect(compareVersions("1.0", "1.0.0")).toBe(0)
|
|
62
|
+
expect(compareVersions("1", "1.0.0")).toBe(0)
|
|
63
|
+
expect(compareVersions("1.0.0", "1.0")).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("handles large version numbers", () => {
|
|
67
|
+
expect(compareVersions("100.200.300", "100.200.300")).toBe(0)
|
|
68
|
+
expect(compareVersions("100.200.300", "100.200.301")).toBe(-1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("handles pre-release versions (strips suffix)", () => {
|
|
72
|
+
expect(compareVersions("4.38.1-beta.1", "4.38.1")).toBe(0)
|
|
73
|
+
expect(compareVersions("4.38.1-dev.16", "4.38.2")).toBe(-1)
|
|
74
|
+
expect(compareVersions("5.0.0-rc.1", "4.99.99")).toBe(1)
|
|
75
|
+
expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(0) // both strip to 1.0.0
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("does not return NaN for pre-release versions", () => {
|
|
79
|
+
const result = compareVersions("4.38.1-beta.1", "4.38.2")
|
|
80
|
+
expect(result).not.toBeNaN()
|
|
81
|
+
expect(result).toBe(-1)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// INTEGRATION: Plugin initialization & hooks
|
|
87
|
+
// =============================================================================
|
|
88
|
+
describe("VersionCheckPlugin", () => {
|
|
89
|
+
let tempDir: string
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tempDir = await createTempDir()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await cleanupTempDir(tempDir)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("returns hooks object with event handler", async () => {
|
|
100
|
+
const ctx = createMockCtx(tempDir)
|
|
101
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
102
|
+
|
|
103
|
+
expect(hooks).toBeDefined()
|
|
104
|
+
expect(hooks.event).toBeFunction()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("event handler is a no-op (does not throw)", async () => {
|
|
108
|
+
const ctx = createMockCtx(tempDir)
|
|
109
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
110
|
+
|
|
111
|
+
// Call event with various event types - should not throw
|
|
112
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
113
|
+
await hooks.event!({ event: { type: "file.edited" } as any })
|
|
114
|
+
await hooks.event!({ event: { type: "unknown.event" } as any })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("does not throw when client.tui is missing", async () => {
|
|
118
|
+
const ctx = {
|
|
119
|
+
directory: tempDir,
|
|
120
|
+
worktree: tempDir,
|
|
121
|
+
client: {},
|
|
122
|
+
project: { name: "test" },
|
|
123
|
+
serverUrl: new URL("http://localhost:3000"),
|
|
124
|
+
$: {},
|
|
125
|
+
}
|
|
126
|
+
// Should not throw even with minimal client
|
|
127
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
128
|
+
expect(hooks).toBeDefined()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("getLocalVersion reads build-info.json via plugin behavior", async () => {
|
|
132
|
+
// Write build-info.json — the plugin reads this internally during setTimeout
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(tempDir, ".opencode", "build-info.json"),
|
|
135
|
+
JSON.stringify({ version: "4.38.0" })
|
|
136
|
+
)
|
|
137
|
+
const ctx = createMockCtx(tempDir)
|
|
138
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
139
|
+
// Plugin initializes successfully
|
|
140
|
+
expect(hooks).toBeDefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("getLocalVersion fallback to config.yaml", async () => {
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(tempDir, ".opencode", "config.yaml"),
|
|
146
|
+
`version: "3.5.0"\nproject_name: test\n`
|
|
147
|
+
)
|
|
148
|
+
const ctx = createMockCtx(tempDir)
|
|
149
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
150
|
+
expect(hooks).toBeDefined()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("getLanguage defaults to 'en' when config missing", async () => {
|
|
154
|
+
// No config.yaml — language detection should default to 'en'
|
|
155
|
+
const ctx = createMockCtx(tempDir)
|
|
156
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
157
|
+
expect(hooks).toBeDefined()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("loads and saves cache via plugin lifecycle", async () => {
|
|
161
|
+
// Write version info so plugin has something to work with
|
|
162
|
+
await writeFile(
|
|
163
|
+
join(tempDir, ".opencode", "build-info.json"),
|
|
164
|
+
JSON.stringify({ version: "4.38.0" })
|
|
165
|
+
)
|
|
166
|
+
const ctx = createMockCtx(tempDir)
|
|
167
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
168
|
+
expect(hooks).toBeDefined()
|
|
169
|
+
// Plugin will attempt to check version in background (setTimeout)
|
|
170
|
+
// No assertion needed — just ensure no crash
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// =============================================================================
|
|
175
|
+
// MEMORY SAFETY
|
|
176
|
+
// =============================================================================
|
|
177
|
+
describe("memory safety", () => {
|
|
178
|
+
let tempDir: string
|
|
179
|
+
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
tempDir = await createTempDir()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
afterEach(async () => {
|
|
185
|
+
await cleanupTempDir(tempDir)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("no state accumulation across multiple event calls", async () => {
|
|
189
|
+
const ctx = createMockCtx(tempDir)
|
|
190
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
191
|
+
|
|
192
|
+
// Call event handler many times - should not accumulate state
|
|
193
|
+
for (let i = 0; i < 1000; i++) {
|
|
194
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// If we get here without OOM or excessive delay, no leak
|
|
198
|
+
expect(true).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("multiple plugin initializations don't leak", async () => {
|
|
202
|
+
const memBefore = process.memoryUsage().heapUsed
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < 50; i++) {
|
|
205
|
+
const dir = await createTempDir()
|
|
206
|
+
const ctx = createMockCtx(dir)
|
|
207
|
+
const hooks = await VersionCheckPlugin(ctx as any)
|
|
208
|
+
await hooks.event!({ event: { type: "session.idle" } as any })
|
|
209
|
+
await cleanupTempDir(dir)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Force GC if available
|
|
213
|
+
if (typeof Bun !== "undefined" && (globalThis as any).gc) {
|
|
214
|
+
;(globalThis as any).gc()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const memAfter = process.memoryUsage().heapUsed
|
|
218
|
+
const growthMB = (memAfter - memBefore) / 1024 / 1024
|
|
219
|
+
|
|
220
|
+
// Memory growth should be < 50MB for 50 iterations
|
|
221
|
+
expect(growthMB).toBeLessThan(50)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -260,8 +260,9 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
260
260
|
const fullPath = join(statePath, stateFile)
|
|
261
261
|
const content = await readFile(fullPath, "utf-8")
|
|
262
262
|
|
|
263
|
-
// Check if this epic is in-progress
|
|
264
|
-
|
|
263
|
+
// Check if this epic is in-progress (normalize variants)
|
|
264
|
+
const contentLower = content.toLowerCase()
|
|
265
|
+
if (contentLower.includes("status: \"in-progress\"") || contentLower.includes("status: in-progress") || contentLower.includes("status: \"in_progress\"") || contentLower.includes("status: in_progress")) {
|
|
265
266
|
// Parse epic state
|
|
266
267
|
const epicIdMatch = content.match(/epic_id:\s*["']?([^"'\n]+)["']?/i)
|
|
267
268
|
const epicTitleMatch = content.match(/epic_title:\s*["']?([^"'\n]+)["']?/i)
|
|
@@ -343,11 +344,18 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
343
344
|
return m ? m[1].trim() : null
|
|
344
345
|
}
|
|
345
346
|
const list = (parent: string, key: string): string[] => {
|
|
347
|
+
// First try inline format: key: [T1, T2] or key: T1, T2
|
|
346
348
|
const val = nested(parent, key)
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
if (val) {
|
|
350
|
+
const clean = val.replace(/^\[/, '').replace(/\]$/, '')
|
|
351
|
+
return clean.split(',').map(s => s.trim()).filter(Boolean)
|
|
352
|
+
}
|
|
353
|
+
// Fallback: YAML block list under parent.key
|
|
354
|
+
const section = content.match(new RegExp(`^${parent}:\\s*\\n([\\s\\S]*?)(?=^\\w+:|$)`, 'm'))
|
|
355
|
+
if (!section) return []
|
|
356
|
+
const keyBlock = section[1].match(new RegExp(`^\\s+${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`, 'm'))
|
|
357
|
+
if (!keyBlock) return []
|
|
358
|
+
return [...keyBlock[1].matchAll(/^\s+-\s+(.+)$/gm)].map(m => m[1].trim())
|
|
351
359
|
}
|
|
352
360
|
|
|
353
361
|
// Parse key_decisions as list
|
|
@@ -422,7 +430,7 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
422
430
|
// Parse story file
|
|
423
431
|
const storyContent = await readFile(join(directory, storyPath), "utf-8")
|
|
424
432
|
const titleMatch = storyContent.match(/^#\s+(.+)/m)
|
|
425
|
-
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
|
|
433
|
+
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
|
|
426
434
|
|
|
427
435
|
const completedTasks: string[] = []
|
|
428
436
|
const pendingTasks: string[] = []
|
|
@@ -547,7 +555,7 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
547
555
|
try {
|
|
548
556
|
const storyContent = await readFile(join(directory, storyPath), "utf-8")
|
|
549
557
|
const titleMatch = storyContent.match(/^#\s+(.+)/m)
|
|
550
|
-
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
|
|
558
|
+
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
|
|
551
559
|
|
|
552
560
|
const completedTasks: string[] = []
|
|
553
561
|
const pendingTasks: string[] = []
|
|
@@ -802,7 +810,8 @@ ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}`)
|
|
|
802
810
|
async function formatInstructions(ctx: SessionContext): Promise<string> {
|
|
803
811
|
const agent = ctx.activeAgent?.toLowerCase()
|
|
804
812
|
const hasInProgressTasks = ctx.todos.some(t => t.status === "in_progress")
|
|
805
|
-
const
|
|
813
|
+
const storyStatus = ctx.story?.status?.toLowerCase().replace(/_/g, '-') || ''
|
|
814
|
+
const hasInProgressStory = storyStatus === "in-progress"
|
|
806
815
|
|
|
807
816
|
// Check if we're in epic workflow
|
|
808
817
|
const epicState = await getActiveEpicState()
|
|
@@ -1022,6 +1031,9 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
|
|
|
1022
1031
|
await log(directory, `=== COMPACTION STARTED ===`)
|
|
1023
1032
|
await log(directory, ` lastActiveAgent: ${lastActiveAgent}`)
|
|
1024
1033
|
|
|
1034
|
+
// Guard: ensure output.context is an array
|
|
1035
|
+
if (!output.context) output.context = []
|
|
1036
|
+
|
|
1025
1037
|
// Use tracked agent or try to detect from session
|
|
1026
1038
|
const agent = lastActiveAgent
|
|
1027
1039
|
const ctx = await buildContext(agent)
|
|
@@ -1039,6 +1051,14 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
|
|
|
1039
1051
|
const ss = ctx.sessionState
|
|
1040
1052
|
const briefing = buildBriefing(agent, ss, ctx, readCommands)
|
|
1041
1053
|
output.context.push(briefing)
|
|
1054
|
+
|
|
1055
|
+
// Push agent-specific context (story details, epic progress, AC) and resume instructions
|
|
1056
|
+
if (context) {
|
|
1057
|
+
output.context.push(context)
|
|
1058
|
+
}
|
|
1059
|
+
if (instructions) {
|
|
1060
|
+
output.context.push(instructions)
|
|
1061
|
+
}
|
|
1042
1062
|
|
|
1043
1063
|
await log(directory, ` -> output.context pushed (${output.context.length} items)`)
|
|
1044
1064
|
await log(directory, `=== COMPACTION DONE ===`)
|