Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 10 additions & 38 deletions packages/opencode/test/cli/run-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect } from "bun:test"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { testEffect } from "../lib/effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { pollForLength, pollUntil } from "../lib/polling"
import { Question } from "../../src/question"
import { Permission } from "../../src/permission"
import { Session } from "../../src/session"
Expand All @@ -20,32 +21,6 @@ const it = testEffect(
),
)

const waitForQuestionCount = (
question: Question.Interface,
count: number,
): Effect.Effect<ReadonlyArray<Question.Request>, Error> =>
Effect.gen(function* () {
for (const _ of Array.from({ length: 100 })) {
const pending = yield* question.list()
if (pending.length === count) return pending
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} question(s)`))
})

const waitForPermissionCount = (
permission: Permission.Interface,
count: number,
): Effect.Effect<ReadonlyArray<Permission.Request>, Error> =>
Effect.gen(function* () {
for (const _ of Array.from({ length: 100 })) {
const pending = yield* permission.list()
if (pending.length === count) return pending
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} permission(s)`))
})

describe("cli/run-events", () => {
it.live("auto-rejects question.asked for the root session (non-attach, non-json)", () =>
provideTmpdirInstance(() =>
Expand Down Expand Up @@ -196,7 +171,7 @@ describe("cli/run-events", () => {
})
.pipe(Effect.forkScoped)

const pending = yield* waitForQuestionCount(question, 1)
const pending = yield* pollForLength(() => question.list(), 1)

expect(handler.stats.autoRejectedQuestions).toBe(0)
expect(pending[0].sessionID).toBe(unrelatedSessionID)
Expand Down Expand Up @@ -244,7 +219,7 @@ describe("cli/run-events", () => {
})
.pipe(Effect.forkScoped)

const pending = yield* waitForQuestionCount(question, 1)
const pending = yield* pollForLength(() => question.list(), 1)
expect(pending[0].sessionID).toBe(deepSessionID)
expect(handler.stats.autoRejectedQuestions).toBe(0)

Expand Down Expand Up @@ -371,15 +346,15 @@ describe("cli/run-events", () => {
})

const yFiber = yield* askPermission(y.id).pipe(Effect.forkScoped)
const firstPending = yield* waitForPermissionCount(permission, 1)
const firstPending = yield* pollForLength(() => permission.list(), 1)
expect(firstPending[0].sessionID).toBe(y.id)
expect(handler.stats.autoRejectedPermissions).toBe(0)
yield* permission.reply({ requestID: firstPending[0].id, reply: "once" })
const yExit = yield* Fiber.await(yFiber)
expect(Exit.isSuccess(yExit)).toBe(true)

const xFiber = yield* askPermission(x.id).pipe(Effect.forkScoped)
const secondPending = yield* waitForPermissionCount(permission, 1)
const secondPending = yield* pollForLength(() => permission.list(), 1)
expect(secondPending[0].sessionID).toBe(x.id)
expect(handler.stats.autoRejectedPermissions).toBe(0)
yield* permission.reply({ requestID: secondPending[0].id, reply: "once" })
Expand Down Expand Up @@ -426,13 +401,10 @@ describe("cli/run-events", () => {
}),
)

yield* Effect.gen(function* () {
for (const _ of Array.from({ length: 100 })) {
if (replies.length === 1) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error("timed out waiting for permission.replied event"))
})
yield* pollUntil(
() => Effect.sync(() => (replies.length === 1 ? Option.some(true) : Option.none())),
{ label: "permission.replied event" },
)

expect(Exit.isSuccess(exit)).toBe(true)
expect(handler.stats.autoRejectedPermissions).toBe(0)
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/test/lib/polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Generic "wait for predicate" helper for test fiber synchronization.
// Replaces per-file bespoke waitForXxx polling loops so the polling
// interval + timeout budget stays consistent across the suite.

import { Effect, Option } from "effect"

export interface PollOptions {
readonly iterations?: number // default 100
readonly intervalMillis?: number // default 10
readonly label?: string // for timeout error message
}

export function pollUntil<A, E, R>(
probe: () => Effect.Effect<Option.Option<A>, E, R>,
opts: PollOptions = {},
): Effect.Effect<A, E | Error, R> {
const iterations = opts.iterations ?? 100
const interval = `${opts.intervalMillis ?? 10} millis` as const
const label = opts.label ?? "pollUntil predicate"
return Effect.gen(function* () {
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iterations is used as an array length (Array.from({ length: iterations })). If a caller passes a negative or non-integer value, this will throw a RangeError rather than failing through the Effect error channel. Consider validating/sanitizing opts.iterations (e.g. clamp to >= 0 and Math.floor) and failing with a clear error when invalid.

Suggested change
return Effect.gen(function* () {
return Effect.gen(function* () {
if (!Number.isInteger(iterations) || iterations < 0) {
return yield* Effect.fail(
new Error(
`invalid iterations for ${label}: expected a non-negative integer, got ${String(iterations)}`,
),
)
}

Copilot uses AI. Check for mistakes.
if (!Number.isInteger(iterations) || iterations < 0) {
return yield* Effect.fail(
new Error(`invalid iterations for ${label}: expected a non-negative integer, got ${String(iterations)}`),
)
}
for (let i = 0; i < iterations; i++) {
const value = yield* probe()
if (Option.isSome(value)) return value.value
yield* Effect.sleep(interval)
}
return yield* Effect.fail(new Error(`timed out waiting for ${label}`))
})
}

export function pollForLength<A, E, R>(
probe: () => Effect.Effect<ReadonlyArray<A>, E, R>,
count: number,
opts: PollOptions = {},
): Effect.Effect<ReadonlyArray<A>, E | Error, R> {
const label = opts.label ?? `list length=${count}`
return Effect.gen(function* () {
if (!Number.isInteger(count) || count < 0) {
return yield* Effect.fail(
new Error(`invalid count for ${label}: expected a non-negative integer, got ${String(count)}`),
)
}
return yield* pollUntil(
() =>
Effect.gen(function* () {
const list = yield* probe()
return list.length === count ? Option.some(list) : Option.none()
}),
{ ...opts, label },
)
})
}
Loading