From cec1255b36e3f2c615082ad71d90eed338a47325 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 11:58:00 -0400 Subject: [PATCH] refactor(question): effectify QuestionService (#17432) --- packages/opencode/src/effect/runtime.ts | 5 +- .../opencode/src/provider/auth-service.ts | 23 ++- packages/opencode/src/question/index.ts | 175 +++-------------- packages/opencode/src/question/schema.ts | 20 +- packages/opencode/src/question/service.ts | 181 ++++++++++++++++++ packages/opencode/src/util/instance-state.ts | 58 +++--- packages/opencode/src/util/schema.ts | 37 ++++ .../opencode/test/question/question.test.ts | 24 ++- .../opencode/test/util/instance-state.test.ts | 51 +++-- 9 files changed, 345 insertions(+), 229 deletions(-) create mode 100644 packages/opencode/src/question/service.ts 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 - 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() } - }), - }) + 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() } + }), + ) 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(f: (service: S.QuestionService.Service) => Effect.Effect) { + 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 - - 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 - - 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 - - export const Answer = z.array(z.string()).meta({ - ref: "QuestionAnswer", - }) - export type Answer = z.infer - - 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 - - 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(), - })) + 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 { - const s = await state() - const id = QuestionID.ascending() - - log.info("asking", { id, questions: input.questions.length }) - - return new Promise((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 { - 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 { - 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()) + return runPromise((service) => service.reject(requestID)) } - export class RejectedError extends Error { - constructor() { - super("The user dismissed this question") - } - } - - export async function list() { - return state().then((x) => Array.from(x.pending.values(), (x) => x.info)) + export async function list(): Promise { + 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", 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()), - })), -) + static readonly zod = Identifier.schema("question") as unknown as z.ZodType +} 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 + +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 + +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 + +export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) +export type Answer = z.infer + +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 + +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", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +export namespace QuestionService { + export interface Service { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } +} + +export class QuestionService extends ServiceMap.Service()( + "@opencode/Question", +) { + static readonly layer = Layer.effect( + QuestionService, + Effect.gen(function* () { + const instanceState = yield* InstanceState.make, QuestionServiceError>(() => + Effect.succeed(new Map()), + ) + + 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() + 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 Disposer = (directory: string) => Effect.Effect +const disposers = new Set() -type Task = (key: string) => Effect.Effect +const TypeId = "~opencode/InstanceState" -const tasks = new Set() +/** + * 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 { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} export namespace InstanceState { - export interface State { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache - } - - export const make = (input: { - lookup: (key: string) => Effect.Effect - release?: (value: A, key: string) => Effect.Effect - }): Effect.Effect, never, R | Scope.Scope> => + /** Create a new InstanceState with the given initializer. */ + export const make = ( + init: (directory: string) => Effect.Effect, + ): Effect.Effect>, never, R | Scope.Scope> => Effect.gen(function* () { const cache = yield* ScopedCache.make({ 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 = (self: State) => ScopedCache.get(self.cache, Instance.directory) + /** Get the cached value for the current directory, initializing it if needed. */ + export const get = (self: InstanceState) => ScopedCache.get(self.cache, Instance.directory) - export const has = (self: State) => ScopedCache.has(self.cache, Instance.directory) + /** Check whether a value exists for the current directory. */ + export const has = (self: InstanceState) => ScopedCache.has(self.cache, Instance.directory) - export const invalidate = (self: State) => ScopedCache.invalidate(self.cache, Instance.directory) + /** Invalidate the cached value for the current directory. */ + export const invalidate = (self: InstanceState) => + 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 = >(methods: (schema: S) => M) => (schema: S): S & M => Object.assign(schema, methods(schema)) + +declare const NewtypeBrand: unique symbol +type NewtypeBrand = { 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", Schema.String) { + * static make(id: string): QuestionID { + * return this.makeUnsafe(id) + * } + * } + * + * Schema.decodeEffect(QuestionID)(input) + */ +export function Newtype() { + return (tag: Tag, schema: S) => { + type Branded = NewtypeBrand + + abstract class Base { + declare readonly [NewtypeBrand]: Tag + + static makeUnsafe(value: Schema.Schema.Type): 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) => Self } + & Omit, "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(state: InstanceState.State, dir: string) { +async function access(state: InstanceState, 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)