Skip to content

Commit 9e7b67c

Browse files
committed
test(run-events): pin descendant walk + cache + depth + skipPermissions-root
Regression tests for Phase B review gaps: grandchild lineage walk (R->M->C), cache non-pollution on unrelated walks, MAX_LINEAGE_DEPTH cutoff, skipPermissions auto-approve for root session. All 4 pass against current implementation — regression pins, not RED tests. Closes coverage gaps flagged in B.3 diamond review.
1 parent bd3dc5e commit 9e7b67c

1 file changed

Lines changed: 220 additions & 1 deletion

File tree

packages/opencode/test/cli/run-events.test.ts

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Permission } from "../../src/permission"
88
import { Session } from "../../src/session"
99
import { Bus } from "../../src/bus"
1010
import { SessionID } from "../../src/session/schema"
11-
import { RunEvents } from "../../src/cli/cmd/run-events"
11+
import { MAX_LINEAGE_DEPTH, RunEvents } from "../../src/cli/cmd/run-events"
1212

1313
const it = testEffect(
1414
Layer.mergeAll(
@@ -33,6 +33,19 @@ const waitForQuestionCount = (
3333
return yield* Effect.fail(new Error(`timed out waiting for ${count} question(s)`))
3434
})
3535

36+
const waitForPermissionCount = (
37+
permission: Permission.Interface,
38+
count: number,
39+
): Effect.Effect<ReadonlyArray<Permission.Request>, Error> =>
40+
Effect.gen(function* () {
41+
for (const _ of Array.from({ length: 100 })) {
42+
const pending = yield* permission.list()
43+
if (pending.length === count) return pending
44+
yield* Effect.sleep("10 millis")
45+
}
46+
return yield* Effect.fail(new Error(`timed out waiting for ${count} permission(s)`))
47+
})
48+
3649
describe("cli/run-events", () => {
3750
it.live("auto-rejects question.asked for the root session (non-attach, non-json)", () =>
3851
provideTmpdirInstance(() =>
@@ -102,6 +115,62 @@ describe("cli/run-events", () => {
102115
),
103116
)
104117

118+
it.live("auto-rejects question.asked across a grandchild lineage walk", () =>
119+
provideTmpdirInstance(() =>
120+
Effect.gen(function* () {
121+
const question = yield* Question.Service
122+
const session = yield* Session.Service
123+
const rootSessionID = SessionID.make("ses_root_grandchild_0000000000000")
124+
const middle = yield* session.create({ parentID: rootSessionID, title: "Middle" })
125+
const child = yield* session.create({ parentID: middle.id, title: "Grandchild" })
126+
const handler = yield* RunEvents.make({
127+
rootSessionID,
128+
skipPermissions: false,
129+
jsonMode: false,
130+
})
131+
132+
const childResult = yield* Effect.exit(
133+
question.ask({
134+
sessionID: child.id,
135+
questions: [
136+
{
137+
question: "first?",
138+
header: "h",
139+
options: [{ label: "a", description: "a" }],
140+
},
141+
],
142+
}),
143+
)
144+
145+
expect(Exit.isFailure(childResult)).toBe(true)
146+
if (Exit.isFailure(childResult)) {
147+
expect(Cause.squash(childResult.cause)).toBeInstanceOf(Question.RejectedError)
148+
}
149+
150+
const middleResult = yield* Effect.exit(
151+
question.ask({
152+
sessionID: middle.id,
153+
questions: [
154+
{
155+
question: "second?",
156+
header: "h",
157+
options: [{ label: "b", description: "b" }],
158+
},
159+
],
160+
}),
161+
)
162+
163+
expect(Exit.isFailure(middleResult)).toBe(true)
164+
if (Exit.isFailure(middleResult)) {
165+
expect(Cause.squash(middleResult.cause)).toBeInstanceOf(Question.RejectedError)
166+
}
167+
expect(handler.stats.autoRejectedQuestions).toBe(2)
168+
169+
yield* Effect.sync(() => handler.unsubscribe())
170+
}),
171+
),
172+
)
173+
105174
it.live("ignores question.asked for an unrelated session tree", () =>
106175
provideTmpdirInstance(() =>
107176
Effect.gen(function* () {
@@ -142,6 +211,53 @@ describe("cli/run-events", () => {
142211
),
143212
)
144213

214+
it.live("does not auto-reject when lineage depth exceeds MAX_LINEAGE_DEPTH", () =>
215+
provideTmpdirInstance(() =>
216+
Effect.gen(function* () {
217+
const question = yield* Question.Service
218+
const session = yield* Session.Service
219+
const rootSessionID = SessionID.make("ses_root_depth_cutoff_000000000000")
220+
const handler = yield* RunEvents.make({
221+
rootSessionID,
222+
skipPermissions: false,
223+
jsonMode: false,
224+
})
225+
226+
const createDeepChild = (parentID: SessionID, remaining: number): Effect.Effect<SessionID> => {
227+
if (remaining === 0) return Effect.succeed(parentID)
228+
return session
229+
.create({ parentID, title: "Depth child" })
230+
.pipe(Effect.flatMap((created) => createDeepChild(created.id, remaining - 1)))
231+
}
232+
233+
const deepSessionID = yield* createDeepChild(rootSessionID, MAX_LINEAGE_DEPTH + 1)
234+
const fiber = yield* question
235+
.ask({
236+
sessionID: deepSessionID,
237+
questions: [
238+
{
239+
question: "deep?",
240+
header: "h",
241+
options: [{ label: "n", description: "n" }],
242+
},
243+
],
244+
})
245+
.pipe(Effect.forkScoped)
246+
247+
const pending = yield* waitForQuestionCount(question, 1)
248+
expect(pending[0].sessionID).toBe(deepSessionID)
249+
expect(handler.stats.autoRejectedQuestions).toBe(0)
250+
251+
yield* question.reject(pending[0].id)
252+
const exit = yield* Fiber.await(fiber)
253+
expect(Exit.isFailure(exit)).toBe(true)
254+
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
255+
256+
yield* Effect.sync(() => handler.unsubscribe())
257+
}),
258+
),
259+
)
260+
145261
it.live("RunEvents.Config does not expose an attach field", () =>
146262
Effect.sync(() => {
147263
const validConfig: RunEvents.Config = {
@@ -229,6 +345,109 @@ describe("cli/run-events", () => {
229345
),
230346
)
231347

348+
it.live("does not cache unrelated walks as descendants for permission.asked", () =>
349+
provideTmpdirInstance(() =>
350+
Effect.gen(function* () {
351+
const permission = yield* Permission.Service
352+
const session = yield* Session.Service
353+
const rootSessionID = SessionID.make("ses_root_cache_guard_0000000000000")
354+
const unrelatedRootSessionID = SessionID.make("ses_unrelated_root_000000000000")
355+
const x = yield* session.create({ parentID: unrelatedRootSessionID, title: "X" })
356+
const y = yield* session.create({ parentID: x.id, title: "Y" })
357+
const handler = yield* RunEvents.make({
358+
rootSessionID,
359+
skipPermissions: false,
360+
jsonMode: false,
361+
})
362+
363+
const askPermission = (sessionID: SessionID) =>
364+
permission.ask({
365+
sessionID,
366+
permission: "bash",
367+
patterns: ["ls"],
368+
metadata: {},
369+
always: [],
370+
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
371+
})
372+
373+
const yFiber = yield* askPermission(y.id).pipe(Effect.forkScoped)
374+
const firstPending = yield* waitForPermissionCount(permission, 1)
375+
expect(firstPending[0].sessionID).toBe(y.id)
376+
expect(handler.stats.autoRejectedPermissions).toBe(0)
377+
yield* permission.reply({ requestID: firstPending[0].id, reply: "once" })
378+
const yExit = yield* Fiber.await(yFiber)
379+
expect(Exit.isSuccess(yExit)).toBe(true)
380+
381+
const xFiber = yield* askPermission(x.id).pipe(Effect.forkScoped)
382+
const secondPending = yield* waitForPermissionCount(permission, 1)
383+
expect(secondPending[0].sessionID).toBe(x.id)
384+
expect(handler.stats.autoRejectedPermissions).toBe(0)
385+
yield* permission.reply({ requestID: secondPending[0].id, reply: "once" })
386+
const xExit = yield* Fiber.await(xFiber)
387+
expect(Exit.isSuccess(xExit)).toBe(true)
388+
389+
const descendant = yield* session.create({ parentID: rootSessionID, title: "Descendant" })
390+
const descendantExit = yield* Effect.exit(askPermission(descendant.id))
391+
expect(Exit.isFailure(descendantExit)).toBe(true)
392+
if (Exit.isFailure(descendantExit)) {
393+
expect(Cause.squash(descendantExit.cause)).toBeInstanceOf(Permission.RejectedError)
394+
}
395+
expect(handler.stats.autoRejectedPermissions).toBe(1)
396+
397+
yield* Effect.sync(() => handler.unsubscribe())
398+
}),
399+
),
400+
)
401+
402+
it.live("auto-approves permission.asked for the root when skipPermissions=true", () =>
403+
provideTmpdirInstance(() =>
404+
Effect.gen(function* () {
405+
const permission = yield* Permission.Service
406+
const bus = yield* Bus.Service
407+
const rootSessionID = SessionID.make("ses_root_skip_perm_root_000000000")
408+
const replies: Array<{ sessionID: SessionID; reply: string }> = []
409+
const unsubscribeReply = yield* bus.subscribeCallback(Permission.Event.Replied, (evt) => {
410+
replies.push({ sessionID: evt.properties.sessionID, reply: evt.properties.reply })
411+
})
412+
const handler = yield* RunEvents.make({
413+
rootSessionID,
414+
skipPermissions: true,
415+
jsonMode: false,
416+
})
417+
418+
const exit = yield* Effect.exit(
419+
permission.ask({
420+
sessionID: rootSessionID,
421+
permission: "bash",
422+
patterns: ["ls"],
423+
metadata: {},
424+
always: [],
425+
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
426+
}),
427+
)
428+
429+
yield* Effect.gen(function* () {
430+
for (const _ of Array.from({ length: 100 })) {
431+
if (replies.length === 1) return
432+
yield* Effect.sleep("10 millis")
433+
}
434+
return yield* Effect.fail(new Error("timed out waiting for permission.replied event"))
435+
})
436+
437+
expect(Exit.isSuccess(exit)).toBe(true)
438+
expect(handler.stats.autoRejectedPermissions).toBe(0)
439+
expect(replies[0]?.sessionID).toBe(rootSessionID)
440+
expect(replies[0]?.reply).toBe("once")
441+
expect(yield* permission.list()).toHaveLength(0)
442+
443+
yield* Effect.sync(() => {
444+
unsubscribeReply()
445+
handler.unsubscribe()
446+
})
447+
}),
448+
),
449+
)
450+
232451
it.live("emits structured JSON event to stdout when jsonMode=true", () =>
233452
provideTmpdirInstance(() =>
234453
Effect.gen(function* () {

0 commit comments

Comments
 (0)