summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-14 11:58:00 -0400
committerGitHub <[email protected]>2026-03-14 11:58:00 -0400
commitcec1255b36e3f2c615082ad71d90eed338a47325 (patch)
treea65f8120566d3a062823300fda78ce3a529b62b9
parent88226f30610d6038a431796a8ae5917199d49c74 (diff)
downloadopencode-cec1255b36e3f2c615082ad71d90eed338a47325.tar.gz
opencode-cec1255b36e3f2c615082ad71d90eed338a47325.zip
refactor(question): effectify QuestionService (#17432)
-rw-r--r--packages/opencode/src/effect/runtime.ts5
-rw-r--r--packages/opencode/src/provider/auth-service.ts23
-rw-r--r--packages/opencode/src/question/index.ts175
-rw-r--r--packages/opencode/src/question/schema.ts20
-rw-r--r--packages/opencode/src/question/service.ts181
-rw-r--r--packages/opencode/src/util/instance-state.ts62
-rw-r--r--packages/opencode/src/util/schema.ts37
-rw-r--r--packages/opencode/test/question/question.test.ts24
-rw-r--r--packages/opencode/test/util/instance-state.test.ts51
9 files changed, 347 insertions, 231 deletions
diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index 23acff733..de4bc3dda 100644
--- a/packages/opencode/src/effect/runtime.ts
+++ b/packages/opencode/src/effect/runtime.ts
@@ -1,5 +1,8 @@
import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
+import { QuestionService } from "@/question/service"
-export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
+export const runtime = ManagedRuntime.make(
+ Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer),
+)
diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts
index 4b5ac1777..2d9cec5cd 100644
--- a/packages/opencode/src/provider/auth-service.ts
+++ b/packages/opencode/src/provider/auth-service.ts
@@ -79,18 +79,17 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
- const state = yield* InstanceState.make({
- lookup: () =>
- Effect.promise(async () => {
- const methods = pipe(
- await Plugin.list(),
- filter((x) => x.auth?.provider !== undefined),
- map((x) => [x.auth!.provider, x.auth!] as const),
- fromEntries(),
- )
- return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
- }),
- })
+ const state = yield* InstanceState.make(() =>
+ Effect.promise(async () => {
+ const methods = pipe(
+ await Plugin.list(),
+ filter((x) => x.auth?.provider !== undefined),
+ map((x) => [x.auth!.provider, x.auth!] as const),
+ fromEntries(),
+ )
+ return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
+ }),
+ )
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
const x = yield* InstanceState.get(state)
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index cf52979fc..fc0c7dd41 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -1,167 +1,44 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "@/session/schema"
-import { Instance } from "@/project/instance"
-import { Log } from "@/util/log"
-import z from "zod"
-import { QuestionID } from "./schema"
+import { Effect } from "effect"
+import { runtime } from "@/effect/runtime"
+import * as S from "./service"
+import type { QuestionID } from "./schema"
+import type { SessionID, MessageID } from "@/session/schema"
+
+function runPromise<A>(f: (service: S.QuestionService.Service) => Effect.Effect<A, S.QuestionServiceError>) {
+ return runtime.runPromise(S.QuestionService.use(f))
+}
export namespace Question {
- const log = Log.create({ service: "question" })
-
- export const Option = z
- .object({
- label: z.string().describe("Display text (1-5 words, concise)"),
- description: z.string().describe("Explanation of choice"),
- })
- .meta({
- ref: "QuestionOption",
- })
- export type Option = z.infer<typeof Option>
-
- export const Info = z
- .object({
- question: z.string().describe("Complete question"),
- header: z.string().describe("Very short label (max 30 chars)"),
- options: z.array(Option).describe("Available choices"),
- multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
- custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
- })
- .meta({
- ref: "QuestionInfo",
- })
- export type Info = z.infer<typeof Info>
-
- export const Request = z
- .object({
- id: QuestionID.zod,
- sessionID: SessionID.zod,
- questions: z.array(Info).describe("Questions to ask"),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
- })
- .meta({
- ref: "QuestionRequest",
- })
- export type Request = z.infer<typeof Request>
-
- export const Answer = z.array(z.string()).meta({
- ref: "QuestionAnswer",
- })
- export type Answer = z.infer<typeof Answer>
-
- export const Reply = z.object({
- answers: z
- .array(Answer)
- .describe("User answers in order of questions (each answer is an array of selected labels)"),
- })
- export type Reply = z.infer<typeof Reply>
-
- export const Event = {
- Asked: BusEvent.define("question.asked", Request),
- Replied: BusEvent.define(
- "question.replied",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- answers: z.array(Answer),
- }),
- ),
- Rejected: BusEvent.define(
- "question.rejected",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- }),
- ),
- }
-
- interface PendingEntry {
- info: Request
- resolve: (answers: Answer[]) => void
- reject: (e: any) => void
- }
-
- const state = Instance.state(async () => ({
- pending: new Map<QuestionID, PendingEntry>(),
- }))
+ export const Option = S.Option
+ export type Option = S.Option
+ export const Info = S.Info
+ export type Info = S.Info
+ export const Request = S.Request
+ export type Request = S.Request
+ export const Answer = S.Answer
+ export type Answer = S.Answer
+ export const Reply = S.Reply
+ export type Reply = S.Reply
+ export const Event = S.Event
+ export const RejectedError = S.RejectedError
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
- const s = await state()
- const id = QuestionID.ascending()
-
- log.info("asking", { id, questions: input.questions.length })
-
- return new Promise<Answer[]>((resolve, reject) => {
- const info: Request = {
- id,
- sessionID: input.sessionID,
- questions: input.questions,
- tool: input.tool,
- }
- s.pending.set(id, {
- info,
- resolve,
- reject,
- })
- Bus.publish(Event.Asked, info)
- })
+ return runPromise((service) => service.ask(input))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
- const s = await state()
- const existing = s.pending.get(input.requestID)
- if (!existing) {
- log.warn("reply for unknown request", { requestID: input.requestID })
- return
- }
- s.pending.delete(input.requestID)
-
- log.info("replied", { requestID: input.requestID, answers: input.answers })
-
- Bus.publish(Event.Replied, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- answers: input.answers,
- })
-
- existing.resolve(input.answers)
+ return runPromise((service) => service.reply(input))
}
export async function reject(requestID: QuestionID): Promise<void> {
- const s = await state()
- const existing = s.pending.get(requestID)
- if (!existing) {
- log.warn("reject for unknown request", { requestID })
- return
- }
- s.pending.delete(requestID)
-
- log.info("rejected", { requestID })
-
- Bus.publish(Event.Rejected, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- })
-
- existing.reject(new RejectedError())
- }
-
- export class RejectedError extends Error {
- constructor() {
- super("The user dismissed this question")
- }
+ return runPromise((service) => service.reject(requestID))
}
- export async function list() {
- return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
+ export async function list(): Promise<Request[]> {
+ return runPromise((service) => service.list())
}
}
diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts
index 65e9ad07c..38b930af1 100644
--- a/packages/opencode/src/question/schema.ts
+++ b/packages/opencode/src/question/schema.ts
@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
-import { withStatics } from "@/util/schema"
+import { Newtype } from "@/util/schema"
-const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
+export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
+ static make(id: string): QuestionID {
+ return this.makeUnsafe(id)
+ }
-export type QuestionID = typeof questionIdSchema.Type
+ static ascending(id?: string): QuestionID {
+ return this.makeUnsafe(Identifier.ascending("question", id))
+ }
-export const QuestionID = questionIdSchema.pipe(
- withStatics((schema: typeof questionIdSchema) => ({
- make: (id: string) => schema.makeUnsafe(id),
- ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
- zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
- })),
-)
+ static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>
+}
diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts
new file mode 100644
index 000000000..6b353c7f1
--- /dev/null
+++ b/packages/opencode/src/question/service.ts
@@ -0,0 +1,181 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { SessionID, MessageID } from "@/session/schema"
+import { InstanceState } from "@/util/instance-state"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
+
+const log = Log.create({ service: "question" })
+
+// --- Zod schemas (re-exported by facade) ---
+
+export const Option = z
+ .object({
+ label: z.string().describe("Display text (1-5 words, concise)"),
+ description: z.string().describe("Explanation of choice"),
+ })
+ .meta({ ref: "QuestionOption" })
+export type Option = z.infer<typeof Option>
+
+export const Info = z
+ .object({
+ question: z.string().describe("Complete question"),
+ header: z.string().describe("Very short label (max 30 chars)"),
+ options: z.array(Option).describe("Available choices"),
+ multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+ custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+ })
+ .meta({ ref: "QuestionInfo" })
+export type Info = z.infer<typeof Info>
+
+export const Request = z
+ .object({
+ id: QuestionID.zod,
+ sessionID: SessionID.zod,
+ questions: z.array(Info).describe("Questions to ask"),
+ tool: z
+ .object({
+ messageID: MessageID.zod,
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({ ref: "QuestionRequest" })
+export type Request = z.infer<typeof Request>
+
+export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+export type Answer = z.infer<typeof Answer>
+
+export const Reply = z.object({
+ answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
+})
+export type Reply = z.infer<typeof Reply>
+
+export const Event = {
+ Asked: BusEvent.define("question.asked", Request),
+ Replied: BusEvent.define(
+ "question.replied",
+ z.object({
+ sessionID: SessionID.zod,
+ requestID: QuestionID.zod,
+ answers: z.array(Answer),
+ }),
+ ),
+ Rejected: BusEvent.define(
+ "question.rejected",
+ z.object({
+ sessionID: SessionID.zod,
+ requestID: QuestionID.zod,
+ }),
+ ),
+}
+
+export class RejectedError extends Error {
+ constructor() {
+ super("The user dismissed this question")
+ }
+}
+
+// --- Effect service ---
+
+export class QuestionServiceError extends Schema.TaggedErrorClass<QuestionServiceError>()("QuestionServiceError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<Answer[]>
+}
+
+export namespace QuestionService {
+ export interface Service {
+ readonly ask: (input: {
+ sessionID: SessionID
+ questions: Info[]
+ tool?: { messageID: MessageID; callID: string }
+ }) => Effect.Effect<Answer[], QuestionServiceError>
+ readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void, QuestionServiceError>
+ readonly reject: (requestID: QuestionID) => Effect.Effect<void, QuestionServiceError>
+ readonly list: () => Effect.Effect<Request[], QuestionServiceError>
+ }
+}
+
+export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
+ "@opencode/Question",
+) {
+ static readonly layer = Layer.effect(
+ QuestionService,
+ Effect.gen(function* () {
+ const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>, QuestionServiceError>(() =>
+ Effect.succeed(new Map<QuestionID, PendingEntry>()),
+ )
+
+ const getPending = InstanceState.get(instanceState)
+
+ const ask = Effect.fn("QuestionService.ask")(function* (input: {
+ sessionID: SessionID
+ questions: Info[]
+ tool?: { messageID: MessageID; callID: string }
+ }) {
+ const pending = yield* getPending
+ const id = QuestionID.ascending()
+ log.info("asking", { id, questions: input.questions.length })
+
+ const deferred = yield* Deferred.make<Answer[]>()
+ const info: Request = {
+ id,
+ sessionID: input.sessionID,
+ questions: input.questions,
+ tool: input.tool,
+ }
+ pending.set(id, { info, deferred })
+ Bus.publish(Event.Asked, info)
+
+ return yield* Deferred.await(deferred)
+ })
+
+ const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+ const pending = yield* getPending
+ const existing = pending.get(input.requestID)
+ if (!existing) {
+ log.warn("reply for unknown request", { requestID: input.requestID })
+ return
+ }
+ pending.delete(input.requestID)
+ log.info("replied", { requestID: input.requestID, answers: input.answers })
+ Bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ answers: input.answers,
+ })
+ yield* Deferred.succeed(existing.deferred, input.answers)
+ })
+
+ const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
+ const pending = yield* getPending
+ const existing = pending.get(requestID)
+ if (!existing) {
+ log.warn("reject for unknown request", { requestID })
+ return
+ }
+ pending.delete(requestID)
+ log.info("rejected", { requestID })
+ Bus.publish(Event.Rejected, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ })
+ yield* Deferred.die(existing.deferred, new RejectedError())
+ })
+
+ const list = Effect.fn("QuestionService.list")(function* () {
+ const pending = yield* getPending
+ return Array.from(pending.values(), (x) => x.info)
+ })
+
+ return QuestionService.of({ ask, reply, reject, list })
+ }),
+ )
+}
diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts
index 5d0ffbf79..15cc3b714 100644
--- a/packages/opencode/src/util/instance-state.ts
+++ b/packages/opencode/src/util/instance-state.ts
@@ -2,34 +2,39 @@ import { Effect, ScopedCache, Scope } from "effect"
import { Instance } from "@/project/instance"
-const TypeId = Symbol.for("@opencode/InstanceState")
-
-type Task = (key: string) => Effect.Effect<void>
-
-const tasks = new Set<Task>()
+type Disposer = (directory: string) => Effect.Effect<void>
+const disposers = new Set<Disposer>()
+
+const TypeId = "~opencode/InstanceState"
+
+/**
+ * Effect version of `Instance.state` — lazily-initialized, per-directory
+ * cached state for Effect services.
+ *
+ * Values are created on first access for a given directory and cached for
+ * subsequent reads. Concurrent access shares a single initialization —
+ * no duplicate work or races. Use `Effect.acquireRelease` in `init` if
+ * the value needs cleanup on disposal.
+ */
+export interface InstanceState<A, E = never, R = never> {
+ readonly [TypeId]: typeof TypeId
+ readonly cache: ScopedCache.ScopedCache<string, A, E, R>
+}
export namespace InstanceState {
- export interface State<A, E = never, R = never> {
- readonly [TypeId]: typeof TypeId
- readonly cache: ScopedCache.ScopedCache<string, A, E, R>
- }
-
- export const make = <A, E = never, R = never>(input: {
- lookup: (key: string) => Effect.Effect<A, E, R>
- release?: (value: A, key: string) => Effect.Effect<void>
- }): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
+ /** Create a new InstanceState with the given initializer. */
+ export const make = <A, E = never, R = never>(
+ init: (directory: string) => Effect.Effect<A, E, R | Scope.Scope>,
+ ): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
- lookup: (key) =>
- Effect.acquireRelease(input.lookup(key), (value) =>
- input.release ? input.release(value, key) : Effect.void,
- ),
+ lookup: init,
})
- const task: Task = (key) => ScopedCache.invalidate(cache, key)
- tasks.add(task)
- yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
+ const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory)
+ disposers.add(disposer)
+ yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer)))
return {
[TypeId]: TypeId,
@@ -37,15 +42,20 @@ export namespace InstanceState {
}
})
- export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
+ /** Get the cached value for the current directory, initializing it if needed. */
+ export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
- export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
+ /** Check whether a value exists for the current directory. */
+ export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
- export const invalidate = <A, E, R>(self: State<A, E, R>) => ScopedCache.invalidate(self.cache, Instance.directory)
+ /** Invalidate the cached value for the current directory. */
+ export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
+ ScopedCache.invalidate(self.cache, Instance.directory)
- export const dispose = (key: string) =>
+ /** Invalidate the given directory across all InstanceState caches. */
+ export const dispose = (directory: string) =>
Effect.all(
- [...tasks].map((task) => task(key)),
+ [...disposers].map((disposer) => disposer(directory)),
{ concurrency: "unbounded" },
)
}
diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts
index 180f952d7..944b7ffcb 100644
--- a/packages/opencode/src/util/schema.ts
+++ b/packages/opencode/src/util/schema.ts
@@ -15,3 +15,40 @@ export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))
+
+declare const NewtypeBrand: unique symbol
+type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
+
+/**
+ * Nominal wrapper for scalar types. The class itself is a valid schema —
+ * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
+ *
+ * @example
+ * class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
+ * static make(id: string): QuestionID {
+ * return this.makeUnsafe(id)
+ * }
+ * }
+ *
+ * Schema.decodeEffect(QuestionID)(input)
+ */
+export function Newtype<Self>() {
+ return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
+ type Branded = NewtypeBrand<Tag>
+
+ abstract class Base {
+ declare readonly [NewtypeBrand]: Tag
+
+ static makeUnsafe(value: Schema.Schema.Type<S>): Self {
+ return value as unknown as Self
+ }
+ }
+
+ Object.setPrototypeOf(Base, schema)
+
+ return Base as unknown as
+ & (abstract new (_: never) => Branded)
+ & { readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self }
+ & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
+ }
+}
diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts
index f00afb09f..ab5bc1d99 100644
--- a/packages/opencode/test/question/question.test.ts
+++ b/packages/opencode/test/question/question.test.ts
@@ -5,6 +5,14 @@ import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
+/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
+async function rejectAll() {
+ const pending = await Question.list()
+ for (const req of pending) {
+ await Question.reject(req.id)
+ }
+}
+
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -24,6 +32,8 @@ test("ask - returns pending promise", async () => {
],
})
expect(promise).toBeInstanceOf(Promise)
+ await rejectAll()
+ await promise.catch(() => {})
},
})
})
@@ -44,7 +54,7 @@ test("ask - adds to pending list", async () => {
},
]
- Question.ask({
+ const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions,
})
@@ -52,6 +62,8 @@ test("ask - adds to pending list", async () => {
const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
+ await rejectAll()
+ await askPromise.catch(() => {})
},
})
})
@@ -98,7 +110,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- Question.ask({
+ const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -119,6 +131,7 @@ test("reply - removes from pending list", async () => {
requestID: pending[0].id,
answers: [["Option 1"]],
})
+ await askPromise
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
@@ -262,7 +275,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- Question.ask({
+ const p1 = Question.ask({
sessionID: SessionID.make("ses_test1"),
questions: [
{
@@ -273,7 +286,7 @@ test("list - returns all pending requests", async () => {
],
})
- Question.ask({
+ const p2 = Question.ask({
sessionID: SessionID.make("ses_test2"),
questions: [
{
@@ -286,6 +299,9 @@ test("list - returns all pending requests", async () => {
const pending = await Question.list()
expect(pending.length).toBe(2)
+ await rejectAll()
+ p1.catch(() => {})
+ p2.catch(() => {})
},
})
})
diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts
index e5d2129fb..19e051f38 100644
--- a/packages/opencode/test/util/instance-state.test.ts
+++ b/packages/opencode/test/util/instance-state.test.ts
@@ -5,7 +5,7 @@ import { Instance } from "../../src/project/instance"
import { InstanceState } from "../../src/util/instance-state"
import { tmpdir } from "../fixture/fixture"
-async function access<A, E>(state: InstanceState.State<A, E>, dir: string) {
+async function access<A, E>(state: InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
@@ -23,9 +23,7 @@ test("InstanceState caches values for the same instance", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const state = yield* InstanceState.make({
- lookup: () => Effect.sync(() => ({ n: ++n })),
- })
+ const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
@@ -45,9 +43,7 @@ test("InstanceState isolates values by directory", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const state = yield* InstanceState.make({
- lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
- })
+ const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const x = yield* Effect.promise(() => access(state, a.path))
const y = yield* Effect.promise(() => access(state, b.path))
@@ -69,13 +65,12 @@ test("InstanceState is disposed on instance reload", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const state = yield* InstanceState.make({
- lookup: () => Effect.sync(() => ({ n: ++n })),
- release: (value) =>
- Effect.sync(() => {
- seen.push(String(value.n))
- }),
- })
+ const state = yield* InstanceState.make(() =>
+ Effect.acquireRelease(
+ Effect.sync(() => ({ n: ++n })),
+ (value) => Effect.sync(() => { seen.push(String(value.n)) }),
+ ),
+ )
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
@@ -96,13 +91,12 @@ test("InstanceState is disposed on disposeAll", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const state = yield* InstanceState.make({
- lookup: (dir) => Effect.sync(() => ({ dir })),
- release: (value) =>
- Effect.sync(() => {
- seen.push(value.dir)
- }),
- })
+ const state = yield* InstanceState.make((dir) =>
+ Effect.acquireRelease(
+ Effect.sync(() => ({ dir })),
+ (value) => Effect.sync(() => { seen.push(value.dir) }),
+ ),
+ )
yield* Effect.promise(() => access(state, a.path))
yield* Effect.promise(() => access(state, b.path))
@@ -121,14 +115,13 @@ test("InstanceState dedupes concurrent lookups for the same directory", async ()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const state = yield* InstanceState.make({
- lookup: () =>
- Effect.promise(async () => {
- n += 1
- await Bun.sleep(10)
- return { n }
- }),
- })
+ const state = yield* InstanceState.make(() =>
+ Effect.promise(async () => {
+ n += 1
+ await Bun.sleep(10)
+ return { n }
+ }),
+ )
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)